import sys
import curses
from typing import Any, Callable, List, Tuple, Optional, Union
from .colors import ColorTheme, _init_curses
from .help import _help, _base_window
from .errors import CursesFzfAborted, CursesFzfAssertion, CursesFzfIndexOutOfBounds
from .scoring import ScoringResult, scoring_fzf
ITEM_COL_START = 2
SELECTED_MARKER = "✅ "
DESELECTED_MARKER = " "
CHAR_CONTINUED = "…"
UnicodeKey = Union[int, str]
[docs]
class FuzzyFinder:
"""
:class:`~curses_fzf.FuzzyFinder` is the main entry point for the library.
Use :meth:`~curses_fzf.FuzzyFinder.find` to run the fuzzyfinder on a list of
items and return the selection.
It allows the user to filter a list of items based on a
:attr:`~curses_fzf.FuzzyFinder.query` entered in UI or preseeded in
constructor.
Args:
multi (bool): :attr:`~curses_fzf.FuzzyFinder.multi` selection mode
determines whether to allow selection of multiple items or not.
Default is ``False``.
title (str): The :attr:`~curses_fzf.FuzzyFinder.title` to display in the
upper left corner of the main :class:`~curses_fzf.FuzzyFinder` window.
Default is ``"ITEMS"``.
query (str): The initial :attr:`~curses_fzf.FuzzyFinder.query` to preseed
the interface with.
Default is ``""``.
See :attr:`~curses_fzf.FuzzyFinder.query` for more details.
display (Callable[[Any], str]): :meth:`~curses_fzf.FuzzyFinder.display` function
is used to convert an item to a string for display and matching purposes.
Default is ``lambda item: str(item)``.
See :meth:`~curses_fzf.FuzzyFinder.display` for more details.
preselect (Callable[[Any, ScoringResult], bool]): :meth:`~curses_fzf.FuzzyFinder.preselect`
is a function to determine if an item should be preselected in
:attr:`~curses_fzf.FuzzyFinder.multi` selection mode based on the item
and its scoring result.
Default is a function that always returns ``False``.
See :meth:`~curses_fzf.FuzzyFinder.preselect` for more details.
preview (Optional[Callable[[curses.window, ColorTheme, Any, ScoringResult], str]]): Show
a preview window if :meth:`~curses_fzf.FuzzyFinder.preview` function is provided.
Default is ``None``.
See :meth:`~curses_fzf.FuzzyFinder.preview` for more details.
score (Callable[[str, str], ScoringResult]): The :meth:`~curses_fzf.FuzzyFinder.score`
function is used to calculate the score of an item from :attr:`~curses_fzf.FuzzyFinder.all_items`
list based on the current :attr:`~curses_fzf.FuzzyFinder.query` and the
:meth:`~curses_fzf.FuzzyFinder.display` string of the item.
Default is :func:`~curses_fzf.scoring_fzf`.
See :meth:`~curses_fzf.FuzzyFinder.score` for more details.
color_theme (Optional[ColorTheme]): The :attr:`~curses_fzf.FuzzyFinder.color_theme`
to use for the interface, if ``None`` was given the default
:class:`~curses_fzf.ColorTheme` will be used.
Default is ``None``.
autoreturn (int): If :attr:`~curses_fzf.FuzzyFinder.autoreturn` is a positive integer,
the :class:`~curses_fzf.FuzzyFinder` will automatically return the items
in :attr:`~curses_fzf.FuzzyFinder.filtered` list without user input if
the number of items in the filtered list matches the given number.
If :attr:`~curses_fzf.FuzzyFinder.multi` is ``False``, the actual value
of :attr:`~curses_fzf.FuzzyFinder.autoreturn` is ignored and the
:class:`~curses_fzf.FuzzyFinder` will automatically return if there is
exactly one item in the filtered list.
Default is ``0``.
min_items (int): The minimum number of selected items to return in
:attr:`~curses_fzf.FuzzyFinder.multi` selection mode.
Below this threshold, the :class:`~curses_fzf.FuzzyFinder` will not
accept the selection on :kbd:`ENTER`.
Default is ``0``.
max_items (int): The maximum number of selected items to return in
:attr:`~curses_fzf.FuzzyFinder.multi` selection mode.
Above this threshold, the :class:`~curses_fzf.FuzzyFinder` will not
accept the selection on :kbd:`ENTER`.
Default is ``sys.maxsize``.
page_size (int): Number of items to move in :attr:`~curses_fzf.FuzzyFinder.filtered`
list when pressing :kbd:`PAGE_UP`/:kbd:`PAGE_DOWN`.
Default is ``10``.
preview_window_percentage (int): :attr:`~curses_fzf.FuzzyFinder.preview_window_percentage`
defines the width of the preview window as a percentage of the total width.
Default is ``40``.
"""
def __init__(self,
multi: bool = False,
title: str = "ITEMS",
query: str = "",
display: Callable[[Any], str] = lambda item: str(item),
preselect: Callable[[Any, ScoringResult], bool] = lambda item, result: False,
preview: Optional[Callable[[curses.window, ColorTheme, Any, ScoringResult], str]] = None,
score: Callable[[str, str], ScoringResult] = scoring_fzf,
color_theme: Optional[ColorTheme] = None,
autoreturn: int = 0,
min_items: int = 0,
max_items: int = sys.maxsize,
page_size: int = 10,
preview_window_percentage: int = 40,
) -> None:
# user settings
self.min_items: int = min_items
"""
The minimum number of selected items to return in
:attr:`~curses_fzf.FuzzyFinder.multi` selection mode.
Below this threshold, the :class:`~curses_fzf.FuzzyFinder` will not
accept the selection on :kbd:`ENTER`.
Default is ``0``.
"""
self.max_items: int = max_items
"""
The maximum number of selected items to return in
:attr:`~curses_fzf.FuzzyFinder.multi` selection mode.
Above this threshold, the :class:`~curses_fzf.FuzzyFinder` will not
accept the selection on :kbd:`ENTER`.
Default is ``sys.maxsize``.
"""
self.title: str = title
"""
The :attr:`~curses_fzf.FuzzyFinder.title` to display in the upper left
corner of the main :class:`~curses_fzf.FuzzyFinder` window.
Default is ``"ITEMS"``.
"""
self.autoreturn: int = autoreturn
"""
If :attr:`~curses_fzf.FuzzyFinder.autoreturn` is a positive integer,
the :class:`~curses_fzf.FuzzyFinder` will automatically return the items
in :attr:`~curses_fzf.FuzzyFinder.filtered` list without user input if
the number of items in the filtered list matches the given number.
If :attr:`~curses_fzf.FuzzyFinder.multi` is ``False``, the actual value
of :attr:`~curses_fzf.FuzzyFinder.autoreturn` is ignored and the
:class:`~curses_fzf.FuzzyFinder` will automatically return if there is
exactly one item in the filtered list.
Default is ``0``.
"""
self.preview_window_percentage: int = preview_window_percentage
"""
:attr:`~curses_fzf.FuzzyFinder.preview_window_percentage` defines the
width of the preview window as a percentage of the total width.
The default value is ``40``.
The preview window will be placed on the right side of the screen if a
:meth:`~curses_fzf.FuzzyFinder.preview` function is provided.
"""
self.page_size: int = page_size
"""
Number of items to move in :attr:`~curses_fzf.FuzzyFinder.filtered` list
when pressing :kbd:`PAGE_UP`/:kbd:`PAGE_DOWN`.
Default is ``10``.
"""
self.multi: bool = multi
"""
:attr:`~curses_fzf.FuzzyFinder.multi` selection mode determines
whether to allow selection of multiple items or not.
Default is ``False``.
"""
if color_theme is None:
color_theme = ColorTheme()
self.color_theme: ColorTheme = color_theme
"""
The :attr:`~curses_fzf.FuzzyFinder.color_theme` to use for the interface.
If ``None`` was given in the constructor, the default
:class:`~curses_fzf.ColorTheme` will be used.
"""
# function pointers
self.display: Callable[[Any], str] = display
"""
:meth:`~curses_fzf.FuzzyFinder.display` function is used to convert an
item to a string for display and matching purposes.
Default is ``lambda item: str(item)``.
Args:
item (Any): The item to convert to a string.
Returns:
str: The single-line string representation of the item.
"""
self.preselect: Callable[[Any, ScoringResult], bool] = preselect
"""
:meth:`~curses_fzf.FuzzyFinder.preselect` is a function to determine if
an item should be preselected in :attr:`~curses_fzf.FuzzyFinder.multi`
selection mode based on the item and its scoring result.
Default is a function that always returns ``False``.
Args:
item (Any): The item from :attr:`~curses_fzf.FuzzyFinder.filtered`
list to determine the preselection for.
scoring_result (ScoringResult): The :class:`~curses_fzf.ScoringResult`
of the item based on the current :attr:`~curses_fzf.FuzzyFinder.query`.
Returns:
bool: Whether the item should be preselected or not.
"""
self.preview: Optional[Callable[[curses.window, ColorTheme, Any, ScoringResult], str]] = preview
"""
If a :meth:`~curses_fzf.FuzzyFinder.preview` function is provided, a
preview window will be shown for the currently highlighted item in the
:attr:`~curses_fzf.FuzzyFinder.filtered` list.
:attr:`~curses_fzf.FuzzyFinder.preview_window_percentage` defines the
width of the preview window as a percentage of the total width.
The preview window can be toggled with :kbd:`Ctrl+P`.
The :meth:`~curses_fzf.FuzzyFinder.preview` function can be used in two ways:
1. If the function returns a non-empty string, it will be rendered line
by line inside the preview window, honoring the available space.
In this case the :py:obj:`curses.window` parameter can be ignored.
2. If the function returns an empty string, :class:`~curses_fzf.FuzzyFinder`
will assume that the user is handling the rendering of the preview window
using the provided :py:obj:`curses.window` parameter.
In this case the user needs to take care of window boundaries.
Args:
preview_window (curses.window): The curses window to render the preview in.
color_theme (ColorTheme): The :attr:`~curses_fzf.FuzzyFinder.color_theme`
of :class:`~curses_fzf.FuzzyFinder`.
item (Any): The item from :attr:`~curses_fzf.FuzzyFinder.filtered` list
to generate the preview for.
score_result (ScoringResult): The :class:`~curses_fzf.ScoringResult` of
the item based on the current :attr:`~curses_fzf.FuzzyFinder.query`.
Returns:
str: The text to render in the preview window, if it is non-empty.
"""
self.score: Callable[[str, str], ScoringResult] = score
"""
The :meth:`~curses_fzf.FuzzyFinder.score` function is used to calculate
the score of an item from :attr:`~curses_fzf.FuzzyFinder.all_items` list
based on the current :attr:`~curses_fzf.FuzzyFinder.query` and the
:meth:`~curses_fzf.FuzzyFinder.display` string of the item.
The :attr:`~curses_fzf.ScoringResult.score` is used to sort the items in
the :attr:`~curses_fzf.FuzzyFinder.filtered` list and to determine which
items match the query.
Default is :func:`~curses_fzf.scoring_fzf`.
Args:
query (str): The current :attr:`~curses_fzf.FuzzyFinder.query`.
This is the string that the user has entered to filter the items.
candidate (str): The string representation of the item as returned by
:meth:`~curses_fzf.FuzzyFinder.display`.
This is the string that is used for matching and display purposes.
Returns:
ScoringResult: The :class:`~curses_fzf.ScoringResult` of the item.
"""
# internal state
self.stdscr: Optional[curses.window] = None
"""
The main curses window, will be set automatically in
:class:`~curses_fzf.FuzzyFinder`'s main loop.
"""
self.all_items: List[Any] = []
"""
The original list of all items given by the user in
:meth:`~curses_fzf.FuzzyFinder.find` method.
This list will be filtered by :attr:`~curses_fzf.FuzzyFinder.query`
into :attr:`~curses_fzf.FuzzyFinder.filtered` list.
"""
self._preseed_query: str = query
"""
Private: A saved copy of the initial query given on initialization,
used to reset the query on :meth:`~curses_fzf.FuzzyFinder.find` call.
"""
self._query: str = query
"""
Private: Use the :attr:`~curses_fzf.FuzzyFinder.query` property to update the query.
"""
self.show_preview: bool = True
"""
Show or hide the preview window.
This can be toggled with :kbd:`Ctrl+P` if a
:meth:`~curses_fzf.FuzzyFinder.preview` function is provided.
It will be set by :meth:`~curses_fzf.FuzzyFinder.kb_toggle_preview`.
"""
self._cursor_items: int = 0
"""
Private: Use the :attr:`~curses_fzf.FuzzyFinder.cursor_items` property
to get the value.
Use :attr:`~curses_fzf.FuzzyFinder.kb_move_items_cursor_absolute`
and :attr:`~curses_fzf.FuzzyFinder.kb_move_items_cursor_relative` to
update the cursor position.
"""
self._cursor_query: int = len(self._query)
"""
Private: Use the :attr:`~curses_fzf.FuzzyFinder.cursor_query` property
to get the value.
Use :attr:`~curses_fzf.FuzzyFinder.kb_move_query_cursor_absolute`
and :attr:`~curses_fzf.FuzzyFinder.kb_move_query_cursor_relative` to
update the cursor position.
"""
self.return_selection_now: bool = False
"""
Whether the :attr:`~curses_fzf.FuzzyFinder` should end the main loop
and return the :attr:`~curses_fzf.FuzzyFinder.selected` items.
This will be set to ``True`` by
:meth:`~curses_fzf.FuzzyFinder.kb_accept_selection` on :kbd:`ENTER`.
"""
self.filtered: List[Tuple[Any, ScoringResult]] = []
"""
The list of items filtered by the current :attr:`~curses_fzf.FuzzyFinder.query`,
each paired with its :class:`~curses_fzf.ScoringResult`.
This list is updated by :meth:`~curses_fzf.FuzzyFinder.calculate_filtered`,
which is called in each iteration of the main loop before rendering the items.
"""
self.selected: List[Any] = []
"""
The list of currently selected items in :attr:`~curses_fzf.FuzzyFinder.multi`
selection mode.
In single selection mode this list will be bypassed and the currently
highlighted item in :attr:`~curses_fzf.FuzzyFinder.filtered` list will
be returned on :kbd:`ENTER`.
"""
# keymap
self.keymap = {
27: {
"function": self.kb_abort_selection,
"key": "ESC",
"description": "Abort fuzzy finder, raising CursesFzfAborted exception.",
"category": "Control Commands",
},
curses.KEY_ENTER: {
"function": self.kb_accept_selection, # 343
"key": "ENTER",
"description": "Accept the current item(s).",
"category": "Control Commands",
},
10: {
"function": self.kb_accept_selection, # linefeed (classic enter key)
},
13: {
"function": self.kb_accept_selection, # carriage return (classic enter key)
},
16: {
"function": self.kb_toggle_preview,
"key": "Ctrl+P",
"description": "Toggle preview window (if a preview function is provided).",
"category": "Control Commands",
},
curses.KEY_F1: {
"function": self.kb_show_help, # 265
"key": "F1",
"description": "Toggle this help screen.",
"category": "Control Commands",
},
curses.KEY_LEFT: {
"function": lambda: self.kb_move_query_cursor_relative(-1), # 260
"key": "ARROW-LEFT",
"description": "Move cursor left 1 position in query.",
"category": "Fuzzy Finder Query",
},
curses.KEY_RIGHT: {
"function": lambda: self.kb_move_query_cursor_relative(1), # 261
"key": "ARROW-RIGHT",
"description": "Move cursor right 1 position in query.",
"category": "Fuzzy Finder Query",
},
11: {
"function": self.kb_reset_query,
"key": "Ctrl+K",
"description": "Clear entire query.",
"category": "Fuzzy Finder Query",
},
curses.KEY_BACKSPACE: {
"function": self.kb_remove_from_query_cursor,
"key": "BACKSPACE",
"description": "Remove the character before the cursor from the query.",
"category": "Fuzzy Finder Query",
},
8: {
"function": self.kb_remove_from_query_cursor,
},
127: {
"function": self.kb_remove_from_query_cursor,
},
curses.KEY_DC: {
"function": lambda: self.kb_remove_from_query_cursor(False), # 330
"key": "DELETE",
"description": "Remove one character at the cursor.",
"category": "Fuzzy Finder Query",
},
9: {
"function": self.kb_toggle_selection,
"key": "TAB",
"description": "Toggle selection of the current item.",
"category": "Item Selection (multi-select only)",
},
1: {
"function": self.kb_select_all,
"key": "Ctrl+A",
"description": "Select all items matching current filter query.",
"category": "Item Selection (multi-select only)",
},
24: {
"function": self.kb_deselect_all,
"key": "Ctrl+X",
"description": "Deselect all items matching current filter query.",
"category": "Item Selection (multi-select only)",
},
curses.KEY_UP: {
"function": lambda: self.kb_move_items_cursor_relative(-1), # 259
"key": "ARROW-UP",
"description": "Move up 1 entry.",
"category": "List Movement",
},
curses.KEY_DOWN: {
"function": lambda: self.kb_move_items_cursor_relative(1), # 258
"key": "ARROW-DOWN",
"description": "Move down 1 entry.",
"category": "List Movement",
},
curses.KEY_PPAGE: {
"function": lambda: self.kb_move_items_cursor_relative(-self.page_size), # 339
"key": "PAGE-UP",
"description": f"Move up by {self.page_size} entries.",
"category": "List Movement",
},
curses.KEY_NPAGE: {
"function": lambda: self.kb_move_items_cursor_relative(self.page_size), # 338
"key": "PAGE-DOWN",
"description": f"Move down by {self.page_size} entries.",
"category": "List Movement",
},
curses.KEY_HOME: {
"function": lambda: self.kb_move_items_cursor_absolute(0), # 262
"key": "HOME",
"description": "Move to first item.",
"category": "List Movement",
},
curses.KEY_END: {
"function": lambda: self.kb_move_items_cursor_absolute(len(self.filtered) - 1), # 360
"key": "END",
"description": "Move to last item.",
"category": "List Movement",
},
}
"""
Dictionary mapping keys (e.g. ``curses.KEY_UP``) to their corresponding
keybinding functions (e.g. ``lambda: self.kb_move_items_cursor_relative(-1)``).
If the target function takes no arguments, it can be directly assigned
like ``curses.KEY_F1: self.kb_show_help``.
All the functions starting with ``kb_`` are keybinding functions that
are used in the keymap and can also be reassigned or called directly.
"""
# properties
@property
def cursor_items(self) -> int:
"""
The index of the cursor inside the :attr:`~curses_fzf.FuzzyFinder.filtered`
list of items.
Use :attr:`~curses_fzf.FuzzyFinder.kb_move_items_cursor_absolute`
and :attr:`~curses_fzf.FuzzyFinder.kb_move_items_cursor_relative` to
update the cursor position.
"""
return self._cursor_items
@property
def cursor_query(self) -> int:
"""
The index of the cursor inside the :attr:`~curses_fzf.FuzzyFinder.query`
string.
Use :attr:`~curses_fzf.FuzzyFinder.kb_move_query_cursor_absolute`
and :attr:`~curses_fzf.FuzzyFinder.kb_move_query_cursor_relative` to
update the cursor position.
"""
return self._cursor_query
@property
def query(self) -> str:
"""
The :attr:`~curses_fzf.FuzzyFinder.query` entered by the user or
preseeded on initialization.
Setting this property will also reset the
:attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor and move the
:attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor to the end of the query.
"""
return self._query
@query.setter
def query(self, value: str) -> None:
if self._query != value:
self._cursor_items = 0
self._cursor_query = len(value)
self._query = value
# main entry point
[docs]
def find(self,
items: List[Any],
title: Optional[str] = None,
query: Optional[str] = None,
) -> List[Any]:
"""
Run the :class:`~curses_fzf.FuzzyFinder` on the given list of items
and return the :attr:`~curses_fzf.FuzzyFinder.selected` item(s).
Args:
items (List[Any]): The list of items to filter and select from.
This list will be stored in :attr:`~curses_fzf.FuzzyFinder.all_items`
and filtered based on the :attr:`~curses_fzf.FuzzyFinder.query`
into :attr:`~curses_fzf.FuzzyFinder.filtered` list.
title (Optional[str]): The :attr:`~curses_fzf.FuzzyFinder.title` to
display in the upper left corner of the main
:class:`~curses_fzf.FuzzyFinder` window.
Default is ``None``, in which case the title given on constructor
will be reused.
query (Optional[str]): The initial :attr:`~curses_fzf.FuzzyFinder.query`
to preseed the interface with.
Default is ``None``, in which case the query given on constructor
will be reused.
See :attr:`~curses_fzf.FuzzyFinder.query` for more details.
Returns:
List[Any]: The list of selected items.
"""
self.all_items = items
if title is not None:
self.title = title
if query is None:
self._query = self._preseed_query
else:
self._query = query
self._preseed_query = self._query
# reset internal state
self._cursor_items = 0
self._cursor_query = len(self._query)
self.show_preview = True
self.return_selection_now = False
self.filtered = []
self.selected = []
try:
return curses.wrapper(lambda stdscr: self._main_loop(stdscr))
except KeyboardInterrupt:
raise CursesFzfAborted("fuzzyfinder aborted by user") from None
# keybinding functions
[docs]
def kb_move_items_cursor_absolute(self, position: int) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Move the :attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor to the
absolute :py:obj:`position` inside the :attr:`~curses_fzf.FuzzyFinder.filtered`
list while keeping it within bounds of the list.
Args:
position (int): The absolute index inside the
:attr:`~curses_fzf.FuzzyFinder.filtered` list to move the cursor to.
"""
self._cursor_items = max(0, min(len(self.filtered) - 1, position))
[docs]
def kb_move_items_cursor_relative(self, offset: int) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Move the :attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor by
:py:obj:`offset` while keeping it within bounds of the
:attr:`~curses_fzf.FuzzyFinder.filtered` list.
Args:
offset (int): The relative offset to move the cursor by inside the
:attr:`~curses_fzf.FuzzyFinder.filtered` list.
Negative values will move the cursor up, positive values will
move it down.
"""
self.kb_move_items_cursor_absolute(self.cursor_items + offset)
[docs]
def kb_move_query_cursor_absolute(self, position: int) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Move the :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor to the
absolute :py:obj:`position` inside the :attr:`~curses_fzf.FuzzyFinder.query`
string while keeping it within bounds of the string.
Args:
position (int): The absolute index inside the
:attr:`~curses_fzf.FuzzyFinder.query` string to move the cursor to.
"""
self._cursor_query = max(0, min(len(self.query), position))
[docs]
def kb_move_query_cursor_relative(self, offset: int) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Move the :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor by
:py:obj:`offset` while keeping it within bounds of the
:attr:`~curses_fzf.FuzzyFinder.query` string.
Args:
offset (int): The relative offset to move the cursor by inside the
:attr:`~curses_fzf.FuzzyFinder.query` string.
Negative values will move the cursor left, positive values will
move it right.
"""
self.kb_move_query_cursor_absolute(self.cursor_query + offset)
[docs]
def kb_abort_selection(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Abort the :class:`~curses_fzf.FuzzyFinder` and raise the
:class:`~curses_fzf.CursesFzfAborted` exception.
"""
raise CursesFzfAborted("fuzzyfinder aborted by user")
[docs]
def kb_accept_selection(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Accept the current :attr:`~curses_fzf.FuzzyFinder.selection` and return it.
This will set :attr:`~curses_fzf.FuzzyFinder.return_selection_now` to ``True``.
"""
if self.multi:
if self.min_items <= len(self.selected) <= self.max_items:
self.return_selection_now = True
# allow empty return in mutli mode but not in single mode
elif self.filtered:
self.return_selection_now = True
[docs]
def kb_toggle_preview(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Toggle the visibility of the :meth:`~curses_fzf.FuzzyFinder.show_preview`
window.
This will toggle the :attr:`~curses_fzf.FuzzyFinder.show_preview` boolean.
"""
self.show_preview = not self.show_preview
[docs]
def kb_toggle_selection(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Toggle the selection state of the current item (only in
:attr:`~curses_fzf.FuzzyFinder.multi` mode).
This will add/remove the item to/from
:attr:`~curses_fzf.FuzzyFinder.selected` list.
"""
if self.multi and self.filtered:
item = self.filtered[self.cursor_items][0]
if item in self.selected:
self.selected.remove(item)
else:
self.selected.append(item)
[docs]
def kb_select_all(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Select all items from the current :attr:`~curses_fzf.FuzzyFinder.filtered`
list (only in :attr:`~curses_fzf.FuzzyFinder.multi` mode).
"""
if self.multi:
for entry in self.filtered:
item = entry[0]
if item not in self.selected:
self.selected.append(item)
[docs]
def kb_deselect_all(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Deselect all items from the current :attr:`~curses_fzf.FuzzyFinder.filtered`
list (only in :attr:`~curses_fzf.FuzzyFinder.multi` mode).
"""
if self.multi:
for entry in self.filtered:
item = entry[0]
if item in self.selected:
self.selected.remove(item)
[docs]
def kb_reset_query(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Clear the :attr:`~curses_fzf.FuzzyFinder.query` string and return the
:attr:`~curses_fzf.FuzzyFinder.cursor_query` and
:attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor to index ``0``.
"""
if self.query:
self._query = ""
self._cursor_items = 0
self._cursor_query = 0
[docs]
def kb_add_to_query(self, text: str, index: int = -1) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Add the given text to the :attr:`~curses_fzf.FuzzyFinder.query` before
the given index.
Adjust the :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor and reset
the :attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor if necessary.
May raise :class:`~curses_fzf.CursesFzfIndexOutOfBounds` if the given
index is out of bounds.
Args:
text (str): The text to add to the :attr:`~curses_fzf.FuzzyFinder.query`
string.
index (int): The index before which to add the text in the
:attr:`~curses_fzf.FuzzyFinder.query` string.
The default is ``-1``, which means to add the text at the end of
the :attr:`~curses_fzf.FuzzyFinder.query` string.
The index may also be negative, in which case it will be counted
from the end of the string (e.g. ``-1`` means before the last
character, ``-2`` means before the second last character, etc.).
"""
if index < 0:
index = len(self.query) + index + 1
if not 0 <= index <= len(self.query):
raise CursesFzfIndexOutOfBounds(
"index to add to query is out of bounds")
self._query = self.query[:index] + text + self.query[index:]
# if the curser is before the insertion leave it where it is
# otherwise move it according to insertion length
if self.cursor_query >= index:
self._cursor_query += len(text)
# we will filter the list by typing a query,
# so the old item cursor index is not valid anymore (it may leak out of
# the list and even if it doesn't a completely other item may be selected)
if text:
self._cursor_items = 0
[docs]
def kb_add_to_query_cursor(self, text: str) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Add the given text to the query at the current
:attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor position.
Adjust the :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor and
reset the :attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor if necessary.
Args:
text (str): The text to add to the :attr:`~curses_fzf.FuzzyFinder.query`
string.
"""
self.kb_add_to_query(text, self.cursor_query)
[docs]
def kb_remove_from_query(self, index: int = -1, length: int = 1) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Remove :py:obj:`length` characters from the query at position
:py:obj:`index` and return the :attr:`~curses_fzf.FuzzyFinder.cursor_items`
cursor to index ``0``.
The :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor will be adjusted
if it is after the remove index.
Args:
index (int): The index at which to remove characters from the
:attr:`~curses_fzf.FuzzyFinder.query` string.
The default is ``-1``, which means to remove the character before
the end of the :attr:`~curses_fzf.FuzzyFinder.query` string.
The index may also be negative, in which case it will be counted
from the end of the string (e.g. ``-1`` means the last character,
``-2`` means the second last character, etc.).
length (int): The number of characters to remove from the
:attr:`~curses_fzf.FuzzyFinder.query` string starting from the
given index. The default is ``1``. The length may be higher than the
actual length of the query, but not negative.
"""
if index < 0:
index = len(self.query) + index
if not 0 <= index < len(self.query):
raise CursesFzfIndexOutOfBounds(
"index to remove from query is out of bounds")
if length < 0:
raise CursesFzfIndexOutOfBounds(
"length to remove from query my not be negative")
if length > 0:
remainder = self.query[index + length:]
self._query = self.query[:index] + remainder
self._cursor_items = 0
# if the cursor is before or at the index leave it where it is
if self.cursor_query > index:
# if the cursor is in the removed part move it to the index
if index <= self.cursor_query <= index + length:
self._cursor_query = index
# otherwise move it according to removed length
else:
self._cursor_query -= length
[docs]
def kb_remove_from_query_cursor(self, before: bool = True) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Remove one character from the :attr:`~curses_fzf.FuzzyFinder.query`
before :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor position
and return the :attr:`~curses_fzf.FuzzyFinder.cursor_items` cursor to
index ``0``.
The :attr:`~curses_fzf.FuzzyFinder.cursor_query` cursor will be adjusted.
Args:
before (bool): Whether to remove the character before the query cursor
(like :kbd:`BACKSPACE`) or the character at the query cursor
(like :kbd:`DEL`).
Default is ``True``.
"""
pos = self.cursor_query
out_of_bounds = False
if before:
pos -= 1
out_of_bounds = pos < 0
else:
out_of_bounds = pos >= len(self.query)
if out_of_bounds:
return
self.kb_remove_from_query(pos, 1)
[docs]
def kb_show_help(self) -> None:
"""
:attr:`~curses_fzf.FuzzyFinder.keymap` function:
Show the help screen, using :kbd:`F1` key.
"""
if self.stdscr:
_help(self.stdscr, self.keymap, self.color_theme)
# loop functions
[docs]
def calculate_filtered(self) -> None:
"""
Calculate the :attr:`~curses_fzf.FuzzyFinder.filtered` list of items
from the :attr:`~curses_fzf.FuzzyFinder.all_items` list
based on the current :attr:`~curses_fzf.FuzzyFinder.query`
and :attr:`~curses_fzf.FuzzyFinder.score` function.
This function will be called in each iteration of the main loop of
:class:`~curses_fzf.FuzzyFinder` before rendering the items.
"""
self.filtered = sorted(
[
(item, score_result) for item in self.all_items
if (score_result := self.score(
self.query, self.display(item))) > 0
],
key=lambda x: x[1], reverse=True
)
def _calculate_preselection(self) -> None:
"""
Calculate the preselected items based on the current filter and
preselection function.
"""
if self.multi:
self.selected = [item_tuple[0] for item_tuple in self.filtered if self.preselect(*item_tuple)]
def _handle_input(self, key: UnicodeKey) -> None:
"""
Handle the given key input by calling the corresponding keybinding function
or adding the character to the query if it is a printable character.
"""
int_key = key if isinstance(key, int) else ord(key)
kb_function = self.keymap.get(int_key, {}).get("function")
if kb_function:
kb_function()
elif isinstance(key, str):
if key.isprintable():
self.kb_add_to_query_cursor(key)
def _get_return_value(self) -> List[Any]:
"""
Get the return value based on the current selection and multi mode.
"""
# in multi mode return the list of selected items, even if it is empty
# in single mode return the selected items list if not empty (e.g. if
# the user manipulated it manually)
if self.multi or self.selected:
return self.selected
# in single mode return the currently highlighted item if there is one,
# otherwise an empty list
return [self.filtered[self.cursor_items][0]] if self.filtered else []
def _autoreturn(self) -> Optional[List[Any]]:
"""
Check if the conditions for autoreturn are met and return the
corresponding items.
If is not 0 return directly if in single mode only 1 item is provided
or left after initial filter.
In multi mode the number of items need to match the number given
as autoreturn's value.
"""
if self.autoreturn:
f_len = len(self.filtered)
if self.multi:
if f_len == self.autoreturn:
return [x[0] for x in self.filtered]
elif f_len == 1:
return [self.filtered[0][0]]
return None
def _render_query(self, width: int) -> None:
"""
Render the query line based on the current query and query cursor.
"""
if self.stdscr is None or width < 10:
return
# render query prompt
self.stdscr.addstr(0, 2, "> ", curses.color_pair(self.color_theme.query))
max_index = -1
# render query characters with highlight on cursor position
for i, c in enumerate(self.query[:width-6]):
color = self.color_theme.query
if self.cursor_query == i:
color = self.color_theme.cursor
self.stdscr.addstr(0, 4 + i, c, curses.color_pair(color))
max_index = i
# if the cursor is at the end of the query render a cursor symbol there
if self.cursor_query == len(self.query) and self.cursor_query < width - 6:
self.stdscr.addstr(0, 5 + max_index, " ", curses.color_pair(self.color_theme.cursor))
def _render_no_match(self, width: int) -> None:
"""
Render the "no match" message if there are no items matching the query.
"""
if self.stdscr is None or width < 10:
return
if not self.filtered:
self.stdscr.addstr(3, ITEM_COL_START + 2, "No matching items!"[:width-6],
curses.color_pair(self.color_theme.no_match))
def _render_viewport(self, height: int, width: int) -> None: # noqa: C901
"""
Render the current viewport of the filtered items based on the current
items cursor.
"""
if self.stdscr is None or height < 7 or width < 10:
return
# query, footer, 2x lines and 1 empty line on top and bottom of list = 6
viewport_height = height - 6
viewport_start = max(0, self.cursor_items - viewport_height + 1)
viewport_end = min(viewport_start + viewport_height, len(self.filtered))
for i in range(viewport_start, viewport_end):
# header, frame & empty line = 3
row = i - viewport_start + 3
item, score_result = self.filtered[i]
display_item = self.display(item)
if len(display_item.splitlines()) > 1:
raise CursesFzfAssertion("display function must return single-line strings")
# chose marker and color based on selection and cursor position
marker = DESELECTED_MARKER
base_color = self.color_theme.text
if i == self.cursor_items and item in self.selected:
marker = SELECTED_MARKER
base_color = self.color_theme.cursor_selected
elif i == self.cursor_items:
base_color = self.color_theme.cursor
elif item in self.selected:
marker = SELECTED_MARKER
base_color = self.color_theme.selected
# render the marker before selected items
self.stdscr.addstr(row, ITEM_COL_START, marker, curses.color_pair(base_color))
# render the item character by character to highlight matched characters
for char_index, char in enumerate(display_item[:width-10]):
color = base_color
for match in score_result.matches:
if match[0] <= char_index < match[0] + len(match[1]):
color = self.color_theme.highlight
self.stdscr.addstr(row, ITEM_COL_START + 3 + char_index,
char, curses.color_pair(color))
# if the line is too long end it with "…"
if len(display_item) > width - 10:
self.stdscr.addstr(row, width - 6, CHAR_CONTINUED, curses.color_pair(base_color))
def _render_preview(self, height: int, width: int) -> Optional[curses.window]:
"""
Render the preview window if it is enabled and a preview function is provided.
"""
if self.stdscr is None:
return None
# deactivate the preview window if the main window gets too small to display it properly
if height < 7 or width < 30:
self.show_preview = False
sub_win = None
if self.show_preview and self.preview is not None:
sub_win = curses.newwin(
height - 4,
int(width * self.preview_window_percentage / 100),
2,
int(width * (100 - self.preview_window_percentage) / 100) - 2
)
sub_win.box()
sub_win.addstr(0, 2, " PREVIEW ",
curses.color_pair(self.color_theme.window_title))
if self.filtered:
text = self.preview(sub_win, self.color_theme, self.filtered[self.cursor_items][0],
self.filtered[self.cursor_items][1])
# if the preview function returns any text assume the user didn't
# use the preview_window parameter and render the text line by line
# inside the preview window, honoring the available space
if text:
sub_h, sub_w = sub_win.getmaxyx()
i = 2
for line in text.splitlines():
if i > sub_h - 3:
break
sub_win.addstr(i, 4, line[:sub_w - 6],
curses.color_pair(self.color_theme.text))
i += 1
return sub_win
def _main_loop(self, stdscr: curses.window) -> List[Any]:
self.stdscr = stdscr
self.calculate_filtered()
autoreturn_value = self._autoreturn()
if autoreturn_value is not None:
return autoreturn_value
self._calculate_preselection()
_init_curses()
while True:
self.calculate_filtered()
# prepare window content
height, width = _base_window(
self.stdscr,
self.title,
(
f"{len(self.selected)} selected | "
f"{len(self.filtered)} matches | ↑↓ = navigate | "
f"{'TAB = toggle | ' if self.multi else ''}"
"ENTER = accept | ESC = abort | F1 = help"
),
self.color_theme,
)
self._render_query(width)
self._render_no_match(width)
self._render_viewport(height, width)
sub_win = self._render_preview(height, width)
# render windows to screen
self.stdscr.refresh()
if sub_win is not None:
sub_win.refresh()
# read input
self._handle_input(self.stdscr.get_wch())
if self.return_selection_now:
return self._get_return_value()