diff --git a/tests/unit/api/test_settings.py b/tests/unit/api/test_settings.py index ead9dc0a0..3f488c7ba 100644 --- a/tests/unit/api/test_settings.py +++ b/tests/unit/api/test_settings.py @@ -151,3 +151,21 @@ def ask_question(*args, **kwargs): mocker.patch("vimiv.api.prompt.ask_question", ask_question) assert bool(prompt_setting) == answer + + +def test_set_order_setting(): + o = settings.OrderSetting("order", "alphabetical") + o.value = "alphabetical-desc" + assert o.value == "alphabetical-desc" + + +def test_set_order_setting_non_str(): + o = settings.OrderSetting("order", "alphabetical") + with pytest.raises(ValueError, match="must be one of"): + o.value = 1 + + +def test_set_order_setting_non_valid(): + o = settings.OrderSetting("order", "alphabetical") + with pytest.raises(ValueError, match="must be one of"): + o.value = "invalid" diff --git a/tests/unit/utils/test_files.py b/tests/unit/utils/test_files.py index 041e9de10..a3007b50d 100644 --- a/tests/unit/utils/test_files.py +++ b/tests/unit/utils/test_files.py @@ -87,12 +87,6 @@ def test_listdir_wrapper_returns_abspath(mocker): assert files.listdir("directory") == expected -def test_listdir_wrapper_sort(mocker): - mocker.patch("os.listdir", return_value=["b.txt", "a.txt"]) - mocker.patch("os.path.abspath", return_value="") - assert files.listdir("directory") == ["a.txt", "b.txt"] - - def test_listdir_wrapper_remove_hidden(mocker): mocker.patch("os.listdir", return_value=[".dotfile.txt", "a.txt"]) mocker.patch("os.path.abspath", return_value="") @@ -122,6 +116,27 @@ def test_images_supported(mocker): assert not directories +@pytest.mark.parametrize( + "input_img, input_dir, sorted_img, sorted_dir", + ( + ( + ["a.j", "c.j", "b.j"], + ["a", "c", "b"], + ["a.j", "b.j", "c.j"], + ["a", "b", "c"], + ), + ( + ["a2.j", "a3.j", "a1.j"], + ["a2", "a3", "a1"], + ["a1.j", "a2.j", "a3.j"], + ["a1", "a2", "a3"], + ), + ), +) +def test_order(input_img, input_dir, sorted_img, sorted_dir): + assert files.order(input_img, input_dir) == (sorted_img, sorted_dir) + + def test_tar_gz_not_an_image(tmp_path): """Test if is_image for a tar.gz returns False. diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 3a08ae85f..7a42874fb 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -332,3 +332,19 @@ def test_is_hex_true(text): @pytest.mark.parametrize("text", ("00x234", "0xGC7", "not-hex", "A-67", "a:9e")) def test_is_hex_false(text): assert not utils.is_hex(text) + + +@pytest.mark.parametrize( + "input_list, sorted_list", + ( + (["a100a", "a10a", "a1a"], ["a1a", "a10a", "a100a"]), + ( + ["a10a1a", "a1a10a", "a10a10a", "a1a1a"], + ["a1a1a", "a1a10a", "a10a1a", "a10a10a"], + ), + (["100", "50", "10", "20"], ["10", "20", "50", "100"]), + (["aa", "a", "aaa"], ["a", "aa", "aaa"]), + ), +) +def test_natural_sort(input_list, sorted_list): + assert sorted(input_list, key=utils.natural_sort) == sorted_list diff --git a/vimiv/api/settings.py b/vimiv/api/settings.py index 36f4a7fcd..31149b411 100644 --- a/vimiv/api/settings.py +++ b/vimiv/api/settings.py @@ -13,12 +13,13 @@ import abc import contextlib import enum -from typing import Any, Dict, ItemsView, List +import os +from typing import Any, Dict, ItemsView, List, Callable, Tuple from PyQt5.QtCore import QObject, pyqtSignal from vimiv.api import prompt -from vimiv.utils import clamp, AbstractQObjectMeta, log, customtypes +from vimiv.utils import clamp, AbstractQObjectMeta, log, customtypes, natural_sort _storage: Dict[str, "Setting"] = {} @@ -315,8 +316,51 @@ def __str__(self) -> str: return "String" -# Initialize all settings +class OrderSetting(Setting): + """Stores an ordering setting.""" + + typ = str + + ORDER_TYPES: Dict[str, Tuple[Callable[..., Any], bool]] = { + "alphabetical": (os.path.basename, False), + "alphabetical-desc": (os.path.basename, True), + "natural": (natural_sort, False), + "natural-desc": (natural_sort, True), + "recently-modify-first": (os.path.getmtime, False), + "recently-modify-last": (os.path.getmtime, True), + } + + def __init__( + self, + *args: Any, + additional_order_types: Dict[str, Tuple[Callable[..., Any], bool]] = None, + **kwargs: Any, + ): + super().__init__(*args, **kwargs) + self.order_types = dict(self.ORDER_TYPES) + if additional_order_types: + self.order_types.update(additional_order_types) + + def convert(self, value: str) -> str: + if value not in self.order_types: + raise ValueError(f"Option must be one of {', '.join(self.ORDER_TYPES)}") + return value + + def get_para(self) -> Tuple[Callable[..., Any], bool]: + """Returns the ordering parameters according to the current set value.""" + try: + return self.order_types[self.value] + except KeyError: + raise ValueError(f"Option must be one of {', '.join(self.ORDER_TYPES)}") + def suggestions(self) -> List[str]: + return [str(value) for value in self.order_types] + + def __str__(self) -> str: + return "Order" + + +# Initialize all settings monitor_fs = BoolSetting( "monitor_filesystem", True, @@ -333,6 +377,24 @@ def __str__(self) -> str: read_only = BoolSetting( "read_only", False, desc="Disable any commands that are able to edit files on disk" ) +image_order = OrderSetting( + "image_order", + "alphabetical", + desc="Set image ordering.", + additional_order_types={ + "smallest-first": (os.path.getsize, False), + "largest-first": (os.path.getsize, True), + }, +) +directory_order = OrderSetting( + "directory_order", + "alphabetical", + desc="Set directory ordering.", + additional_order_types={ + "smallest-first": (lambda e: len(os.listdir(e)), True), + "largest-first": (lambda e: len(os.listdir(e)), False), + }, +) class command: # pylint: disable=invalid-name diff --git a/vimiv/api/working_directory.py b/vimiv/api/working_directory.py index 08203d35b..ace734caa 100644 --- a/vimiv/api/working_directory.py +++ b/vimiv/api/working_directory.py @@ -109,6 +109,8 @@ def __init__(self) -> None: self._directories: List[str] = [] settings.monitor_fs.changed.connect(self._on_monitor_fs_changed) + settings.image_order.changed.connect(self._reload_directory) + settings.directory_order.changed.connect(self._reload_directory) # TODO Fix upstream and open PR self.directoryChanged.connect(self._reload_directory) # type: ignore self.fileChanged.connect(self._on_file_changed) # type: ignore @@ -219,12 +221,12 @@ def _get_content(self, directory: str) -> Tuple[List[str], List[str]]: """Get supported content of directory. Returns: - images: List of images inside the directory. - directories: List of directories inside the directory. + images: Ordered list of images inside the directory. + directories: Ordered list of directories inside the directory. """ show_hidden = settings.library.show_hidden.value paths = files.listdir(directory, show_hidden=show_hidden) - return files.supported(paths) + return files.order(*files.supported(paths)) handler = cast(WorkingDirectoryHandler, None) diff --git a/vimiv/utils/__init__.py b/vimiv/utils/__init__.py index 02cf77c39..92d9e8a5c 100644 --- a/vimiv/utils/__init__.py +++ b/vimiv/utils/__init__.py @@ -526,3 +526,15 @@ def stop_all(cls): """Stop all running throttles.""" for throttle in cls.throttles: throttle.stop() + + +def natural_sort(text: str) -> typing.List[typing.Union[str, int]]: + """Key function for natural sort. + + Credits to https://stackoverflow.com/a/5967539/5464989 + """ + + def convert(t: str) -> typing.Union[str, int]: + return int(t) if t.isdigit() else t + + return [convert(c) for c in re.split(r"(\d+)", text)] diff --git a/vimiv/utils/files.py b/vimiv/utils/files.py index 7ccbcb375..832e9cfd6 100644 --- a/vimiv/utils/files.py +++ b/vimiv/utils/files.py @@ -15,6 +15,8 @@ from vimiv.utils import imagereader +from vimiv import api + ImghdrTestFuncT = Callable[[bytes, Optional[BinaryIO]], bool] @@ -26,13 +28,18 @@ def listdir(directory: str, show_hidden: bool = False) -> List[str]: directory: Directory to check for files in via os.listdir(directory). show_hidden: Include hidden files in output. Returns: - Sorted list of files in the directory with their absolute path. + List of files in the directory with their absolute path. """ directory = os.path.abspath(os.path.expanduser(directory)) + order_function, reverse = api.settings.image_order.get_para() return sorted( - os.path.join(directory, path) - for path in os.listdir(directory) - if show_hidden or not path.startswith(".") + [ + os.path.join(directory, path) + for path in os.listdir(directory) + if show_hidden or not path.startswith(".") + ], + key=order_function, + reverse=reverse, ) @@ -55,6 +62,24 @@ def supported(paths: Iterable[str]) -> Tuple[List[str], List[str]]: return images, directories +def order(images: List[str], directories: List[str]) -> Tuple[List[str], List[str]]: + """Orders images and directories according to the settings. + + Args: + files: Tuple of list of images and list of directories. + Returns: + images: Ordered list of images. + directories: Orderd list of directories. + """ + + image_order, image_reverse = api.settings.image_order.get_para() + directory_order, directory_reverse = api.settings.directory_order.get_para() + images = sorted(images, key=image_order, reverse=image_reverse) + directories = sorted(directories, key=directory_order, reverse=directory_reverse) + + return images, directories + + def get_size(path: str) -> str: """Get the size of a path in human readable format.