├── .bumpversion.cfg ├── .codecov.yml ├── .coveragerc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── docs.yml │ └── pythonpublish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pydocstylerc ├── .pylintrc ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── README.md ├── c-extension ├── brightness_contrast.h ├── definitions.h ├── helper_func.h ├── hue_saturation_lightness.h ├── manipulate.c └── math_func_eval.h ├── docs ├── Makefile ├── _static │ ├── custom.css │ ├── favicon.ico │ ├── scrots │ │ ├── command_dark.png │ │ ├── command_light.png │ │ ├── image_dark.png │ │ ├── image_light.png │ │ ├── library_dark.png │ │ ├── library_light.png │ │ ├── manipulate_dark.png │ │ ├── manipulate_light.png │ │ ├── thumbnail_dark.png │ │ └── thumbnail_light.png │ └── vimiv │ │ ├── vimiv.png │ │ ├── vimiv.svg │ │ ├── vimiv_banner_800.png │ │ └── vimiv_banner_darkmode_800.png ├── changelog.rst ├── conf.py ├── description.rst ├── documentation │ ├── cl_options │ │ └── index.rst │ ├── commands.rst │ ├── configuration │ │ ├── index.rst │ │ ├── keybindings.rst │ │ ├── plugins.rst │ │ ├── settings.rst │ │ ├── statusbar.rst │ │ └── style.rst │ ├── contributing.rst │ ├── contributing_bugs.rst │ ├── datafile_warning.rst │ ├── dependency_info.rst │ ├── getting_help.rst │ ├── getting_started.rst │ ├── hacking.rst │ ├── index.rst │ ├── install.rst │ ├── metadata.rst │ ├── migrating.rst │ └── updating_icon_cache.rst ├── index.rst ├── manpage │ └── vimiv.1.rst ├── screenshots.rst └── theme.conf ├── fastentrypoints.py ├── icons ├── vimiv.svg ├── vimiv_128x128.png ├── vimiv_16x16.png ├── vimiv_256x256.png ├── vimiv_32x32.png ├── vimiv_512x512.png └── vimiv_64x64.png ├── misc ├── Makefile ├── org.karlch.vimiv.qt.metainfo.xml ├── requirements │ ├── requirements_cov.txt │ ├── requirements_docs.txt │ ├── requirements_lint.txt │ ├── requirements_mypy.txt │ ├── requirements_packaging.txt │ ├── requirements_piexif.txt │ ├── requirements_pyexiv2.txt │ ├── requirements_pyqt5.txt │ ├── requirements_pyqt6.txt │ ├── requirements_pyside6.txt │ ├── requirements_setup.txt │ ├── requirements_tests.txt │ └── requirements_tox.txt ├── vimiv.1 └── vimiv.desktop ├── mypy.ini ├── pytest.ini ├── scripts ├── gen_manpage.sh ├── lint_tests.py ├── maybe_build_cextension.py ├── pylint_checkers │ ├── __init__.py │ ├── check_count.py │ ├── check_docstring.py │ └── check_header.py ├── rstutils.py ├── src2rst.py ├── uninstall_pythonpkg.sh └── vimiv_history.py ├── setup.py ├── tests ├── conftest.py ├── end2end │ ├── conftest.py │ ├── features │ │ ├── api │ │ │ ├── conftest.py │ │ │ ├── keybindings.feature │ │ │ ├── mark.feature │ │ │ ├── modeswitch.feature │ │ │ ├── print.feature │ │ │ ├── prompt.feature │ │ │ ├── rename.feature │ │ │ ├── test_keybindings_bdd.py │ │ │ ├── test_mark_bdd.py │ │ │ ├── test_modeswitch_bdd.py │ │ │ ├── test_print_bdd.py │ │ │ ├── test_prompt_bdd.py │ │ │ ├── test_rename_bdd.py │ │ │ ├── test_working_directory_bdd.py │ │ │ └── working_directory.feature │ │ ├── command │ │ │ ├── aliases.feature │ │ │ ├── chaining.feature │ │ │ ├── conftest.py │ │ │ ├── expand_wildcards.feature │ │ │ ├── external.feature │ │ │ ├── fail_run_command.feature │ │ │ ├── misccommands.feature │ │ │ ├── repeat.feature │ │ │ ├── search.feature │ │ │ ├── test_aliases_bdd.py │ │ │ ├── test_chaining_bdd.py │ │ │ ├── test_expand_wildcards_bdd.py │ │ │ ├── test_external_bdd.py │ │ │ ├── test_fail_run_command_bdd.py │ │ │ ├── test_misccommands_bdd.py │ │ │ ├── test_repeat_bdd.py │ │ │ └── test_search_bdd.py │ │ ├── commandline │ │ │ ├── commandline.feature │ │ │ └── test_commandline_bdd.py │ │ ├── completion │ │ │ ├── completion.feature │ │ │ └── test_completion_bdd.py │ │ ├── config │ │ │ ├── configcommands.feature │ │ │ └── test_configcommands_bdd.py │ │ ├── conftest.py │ │ ├── edit │ │ │ ├── conftest.py │ │ │ ├── crop.feature │ │ │ ├── manipulate.feature │ │ │ ├── manipulate_segfault.feature │ │ │ ├── straighten.feature │ │ │ ├── test_crop_bdd.py │ │ │ ├── test_manipulate_bdd.py │ │ │ ├── test_straighten_bdd.py │ │ │ ├── test_transform_bdd.py │ │ │ └── transform.feature │ │ ├── image │ │ │ ├── conftest.py │ │ │ ├── gif.feature │ │ │ ├── imagedelete.feature │ │ │ ├── imagefit.feature │ │ │ ├── imagenavigate.feature │ │ │ ├── imageopen.feature │ │ │ ├── imageorder.feature │ │ │ ├── imagescroll.feature │ │ │ ├── imagetitle.feature │ │ │ ├── imagezoom.feature │ │ │ ├── metadata.feature │ │ │ ├── multidirectory.feature │ │ │ ├── slideshow.feature │ │ │ ├── svg.feature │ │ │ ├── test_gif_bdd.py │ │ │ ├── test_imagedelete_bdd.py │ │ │ ├── test_imagefit_bdd.py │ │ │ ├── test_imagenavigate_bdd.py │ │ │ ├── test_imageopen_bdd.py │ │ │ ├── test_imageorder_bdd.py │ │ │ ├── test_imagescroll_bdd.py │ │ │ ├── test_imagetitle_bdd.py │ │ │ ├── test_imagezoom_bdd.py │ │ │ ├── test_metadata_bdd.py │ │ │ ├── test_multidirectory_bdd.py │ │ │ ├── test_slideshow_bdd.py │ │ │ ├── test_svg_bdd.py │ │ │ ├── test_write_bdd.py │ │ │ └── write.feature │ │ ├── library │ │ │ ├── library.feature │ │ │ ├── libraryresize.feature │ │ │ ├── libraryscroll.feature │ │ │ ├── test_library_bdd.py │ │ │ ├── test_libraryresize_bdd.py │ │ │ └── test_libraryscroll_bdd.py │ │ ├── misc │ │ │ ├── clipboard.feature │ │ │ ├── conftest.py │ │ │ ├── fullscreen.feature │ │ │ ├── keybindings_popup.feature │ │ │ ├── keyhint.feature │ │ │ ├── migration_popup.feature │ │ │ ├── no_optional.feature │ │ │ ├── startup.feature │ │ │ ├── symlink.feature │ │ │ ├── teardown.feature │ │ │ ├── test_clipboard_bdd.py │ │ │ ├── test_fullscreen_bdd.py │ │ │ ├── test_keybindings_popup_bdd.py │ │ │ ├── test_keyhint_bdd.py │ │ │ ├── test_migration_popup_bdd.py │ │ │ ├── test_no_optional_bdd.py │ │ │ ├── test_startup_bdd.py │ │ │ ├── test_symlink_bdd.py │ │ │ ├── test_teardown_bdd.py │ │ │ ├── test_version_bdd.py │ │ │ └── version.feature │ │ ├── plugins │ │ │ ├── plugins.feature │ │ │ └── test_plugins_bdd.py │ │ ├── statusbar │ │ │ ├── message.feature │ │ │ ├── status.feature │ │ │ ├── test_message_bdd.py │ │ │ └── test_status_bdd.py │ │ └── thumbnail │ │ │ ├── .zcompdump │ │ │ ├── conftest.py │ │ │ ├── test_thumbnailgoto_bdd.py │ │ │ ├── test_thumbnailmark_bdd.py │ │ │ ├── test_thumbnailscroll_bdd.py │ │ │ ├── test_thumbnailzoom_bdd.py │ │ │ ├── thumbnailgoto.feature │ │ │ ├── thumbnailmark.feature │ │ │ ├── thumbnailscroll.feature │ │ │ └── thumbnailzoom.feature │ └── mockdecorators.py ├── integration │ ├── __init__.py │ ├── conftest.py │ ├── test_app.py │ ├── test_edit.py │ ├── test_metadata.py │ ├── test_read_bindings.py │ └── test_read_settings.py └── unit │ ├── __init__.py │ ├── api │ ├── test_keybindings.py │ ├── test_mark.py │ ├── test_objreg.py │ ├── test_prompt.py │ ├── test_settings.py │ └── test_status.py │ ├── commands │ ├── test_aliases.py │ ├── test_argtypes.py │ ├── test_commands.py │ ├── test_history.py │ ├── test_history_deque.py │ ├── test_runners.py │ ├── test_search.py │ └── test_wildcards.py │ ├── config │ ├── test_config.py │ ├── test_external_configparser.py │ └── test_styles.py │ ├── conftest.py │ ├── gui │ ├── test_eventhandler.py │ ├── test_statusbar.py │ └── test_thumbnail.py │ ├── imutils │ └── test_imtransform.py │ ├── plugins │ ├── mock_plugin.py │ ├── mock_plugin_syntax_error.py │ └── test_plugins.py │ ├── test_checkversion.py │ ├── test_parser.py │ ├── test_version.py │ └── utils │ ├── _module_for_lazy.py │ ├── test_crash_handler.py │ ├── test_debug.py │ ├── test_files.py │ ├── test_imageheader.py │ ├── test_lazy.py │ ├── test_log.py │ ├── test_migration.py │ ├── test_thumbnail_manager.py │ ├── test_trash_manager.py │ ├── test_trie.py │ ├── test_utils.py │ └── test_xdg.py ├── tox.ini └── vimiv ├── __init__.py ├── __main__.py ├── api ├── __init__.py ├── _mark.py ├── _modules.py ├── commands.py ├── completion.py ├── keybindings.py ├── modes.py ├── objreg.py ├── prompt.py ├── settings.py ├── signals.py ├── status.py └── working_directory.py ├── app.py ├── checkversion.py ├── commands ├── __init__.py ├── aliases.py ├── argtypes.py ├── delete_command.py ├── external.py ├── help_command.py ├── history.py ├── misccommands.py ├── runners.py ├── search.py └── wildcards.py ├── completion ├── __init__.py ├── completer.py └── completionmodels.py ├── config ├── __init__.py ├── _style_options.py ├── configcommands.py ├── configfile.py ├── external_configparser.py ├── keyfile.py └── styles.py ├── gui ├── __init__.py ├── commandline.py ├── commandwidget.py ├── completionwidget.py ├── crop_widget.py ├── eventhandler.py ├── image.py ├── keybindings_popup.py ├── keyhintwidget.py ├── library.py ├── mainwindow.py ├── manipulate.py ├── message.py ├── metadatawidget.py ├── prompt.py ├── resize.py ├── statusbar.py ├── straightenwidget.py ├── synchronize.py ├── thumbnail.py ├── transformwidget.py └── version_popup.py ├── imutils ├── __init__.py ├── _file_handler.py ├── current_pixmap.py ├── edit_handler.py ├── filelist.py ├── immanipulate.py ├── imtransform.py ├── metadata.py └── slideshow.py ├── parser.py ├── plugins ├── __init__.py ├── demo.py ├── imageformats.py ├── metadata.py ├── metadata_piexif.py ├── metadata_pyexiv2.py └── print.py ├── qt ├── __init__.py ├── core.py ├── gui.py ├── printsupport.py ├── svg.py └── widgets.py ├── startup.py ├── utils ├── __init__.py ├── crash_handler.py ├── customtypes.py ├── debug.py ├── files.py ├── imageheader.py ├── imagereader.py ├── lazy.py ├── log.py ├── migration.py ├── thumbnail_manager.py ├── trash_manager.py ├── trie.py └── xdg.py ├── version.py └── widgets.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.9.0 3 | commit = True 4 | message = Release v{new_version} 5 | tag = True 6 | tag_name = v{new_version} 7 | 8 | [bumpversion:file:vimiv/__init__.py] 9 | serialize = ({major}, {minor}, {patch}) 10 | 11 | [bumpversion:file:misc/org.karlch.vimiv.qt.metainfo.xml] 12 | search = 13 | replace = 14 | 15 | 16 | [bumpversion:file:docs/changelog.rst] 17 | search = (unreleased) 18 | replace = ({now:%Y-%m-%d}) 19 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: 7 | default: 8 | informational: true 9 | changes: off 10 | 11 | comment: off 12 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = *__main__.py 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | coverage: ignore 8 | raise NotImplementedError 9 | if __name__ == "__main__" 10 | @utils.asyncfunc 11 | @asyncfunc 12 | def __repr__ 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | - package-ecosystem: pip 5 | directory: "/misc/requirements" 6 | schedule: 7 | interval: weekly 8 | day: saturday 9 | time: "04:00" 10 | pull-request-branch-name: 11 | separator: "-" 12 | open-pull-requests-limit: 10 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: weekly 18 | day: saturday 19 | time: "04:00" 20 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Rebuild gh-pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | docs: 10 | 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4.1.5 14 | - uses: actions/cache@v4 15 | with: 16 | path: | 17 | .tox 18 | ~/.cache/pip 19 | key: "ghpages_${{ hashFiles('misc/requirements/requirements*.txt') }}_${{ hashFiles('scripts/src2rst.py') }}" 20 | - name: Set up python 21 | uses: actions/setup-python@v5.1.0 22 | with: 23 | python-version: '3.11' 24 | - name: Install dependencies 25 | run: | 26 | sudo apt-get install libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 libxcb-shape0 libegl1 libxcb-cursor0 27 | python -m pip install --upgrade pip 28 | pip install -r misc/requirements/requirements_tox.txt 29 | - name: Clone gh-pages branch 30 | run: 31 | git clone https://github.com/karlch/vimiv-qt.git gh-pages --branch gh-pages --single-branch 32 | - name: Rebuild documentation with tox 33 | run: | 34 | tox -e docs -- gh-pages 35 | - name: Commit documentation changes 36 | run: | 37 | cd gh-pages 38 | git config --local user.email "karlch@users.noreply.github.com" 39 | git config --local user.name "Christian Karl" 40 | git add . 41 | git commit -a -m "Automatic update of gh-pages website" -m "Caused by $(git --git-dir ../.git rev-parse HEAD)." || true 42 | - name: Push changes 43 | uses: ad-m/github-push-action@master 44 | with: 45 | branch: gh-pages 46 | directory: gh-pages 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4.1.5 13 | - name: Set up Python 14 | uses: actions/setup-python@v5.1.0 15 | with: 16 | python-version: '3.x' 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install setuptools wheel twine 21 | - name: Build and publish 22 | env: 23 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 24 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 25 | run: | 26 | python setup.py sdist 27 | twine upload dist/* 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | *.egg-info 3 | *.cache 4 | *.coverage 5 | *.hypothesis 6 | *.tox 7 | coverage/* 8 | build* 9 | dist* 10 | install_log.txt 11 | **/*.so 12 | docs/_build 13 | TODO 14 | .pytest_cache 15 | Makefile 16 | .venv 17 | **/.doctrees 18 | **/.mypy_cache 19 | **/.dmypy.json 20 | **/*.rstsrc 21 | tests/data 22 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.1.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 24.3.0 10 | hooks: 11 | - id: black 12 | args: [--force-exclude, .*syntax_error.*.py] 13 | -------------------------------------------------------------------------------- /.pydocstylerc: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | # Disabled: 3 | # D100-105: Check for docstrings is handled by pylint. 4 | # D107: Missing docstring in __init__, not always needed IMHO 5 | # D202: No blank lines allowed after function docstring, false positives with decorators 6 | # D402: First line should not be function's "signature", false positives 7 | # D413: Multi-line docstring summary should start at the second line 8 | # pep257: D203,D212,D213,D214,D215,D404,D405,D406,D407,D408,D409,D410,D411 9 | ignore = D100,D101,D102,D103,D105,D105,D107,D202,D203,D212,D213,D214,D215,D401,D402,D404,D405,D406,D407,D408,D409,D410,D411,D413 10 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | # vim: ft=dosini fileencoding=utf-8: 2 | [MASTER] 3 | extension-pkg-whitelist=PyQt5, 4 | vimiv.imutils._c_manipulate 5 | # Add custom checkers 6 | init-hook='import sys; sys.path.append("./scripts/pylint_checkers/")' 7 | load-plugins=check_count, 8 | check_docstring, 9 | check_header, 10 | pylint.extensions.check_elif, 11 | pylint.extensions.overlapping_exceptions, 12 | pylint.extensions.for_any_all, 13 | 14 | [MESSAGES CONTROL] 15 | enable=all 16 | disable=fixme, 17 | cyclic-import, 18 | global-statement, 19 | locally-disabled, 20 | too-many-ancestors, 21 | too-few-public-methods, 22 | blacklisted-name, 23 | file-ignored, 24 | wrong-import-position, 25 | ungrouped-imports, 26 | suppressed-message, 27 | arguments-differ, 28 | duplicate-code, 29 | import-outside-toplevel, 30 | raise-missing-from, 31 | arguments-renamed, 32 | 33 | [BASIC] 34 | function-rgx=[a-z_][a-z0-9_]{2,50}$ 35 | const-rgx=[A-Za-z_][A-Za-z0-9_]{0,30}$ 36 | class-const-rgx=[A-Za-z_][A-Za-z0-9_]{0,30}$ 37 | method-rgx=[a-z_][A-Za-z0-9_]{2,50}$ 38 | attr-rgx=[a-z_][a-z0-9_]{0,30}$ 39 | argument-rgx=[a-z_][a-z0-9_]{0,30}$ 40 | variable-rgx=[a-z_][a-z0-9_]{0,30}$ 41 | docstring-min-length=3 42 | no-docstring-rgx=(^__|^main$|^test_|decorator|inside|^_on) 43 | 44 | [FORMAT] 45 | max-line-length=88 46 | max-module-lines=1000 47 | ignore-long-lines=( 9 | Wolfgang Popp (woefe) 10 | Ankur Sinha (sanjayankur31) 11 | Jean-Claude Graf (jcjgraf) 12 | Mateus Etto (Yutsuten) 13 | buzzingwires (buzzingwires) 14 | 15 | Please send an email to or open an issue / pull request 16 | if you feel like your name is missing or in case of any other problems. 17 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft c-extension 2 | 3 | graft icons 4 | 5 | include AUTHORS 6 | include LICENSE 7 | 8 | prune docs 9 | include docs/description.rst 10 | 11 | prune tests 12 | 13 | prune scripts 14 | 15 | include misc/Makefile 16 | include misc/vimiv.1 17 | include misc/vimiv.desktop 18 | include misc/org.karlch.vimiv.qt.metainfo.xml 19 | graft misc/requirements 20 | 21 | include fastentrypoints.py 22 | -------------------------------------------------------------------------------- /c-extension/brightness_contrast.h: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * C extension for vimiv 3 | * Functions to enhance brightness and contrast of an image. 4 | *******************************************************************************/ 5 | 6 | #include "definitions.h" 7 | #include "helper_func.h" 8 | #include "math_func_eval.h" 9 | 10 | /** 11 | * Enhance brightness using the GIMP algorithm. 12 | * 13 | * @param value Current R/G/B value of the pixel. 14 | * @param factor Factor to enhance brightness by. 15 | */ 16 | static inline float enhance_brightness(float value, float factor) 17 | { 18 | if (factor < 0) 19 | return value * (1 + factor); 20 | return value + (1 - value) * factor; 21 | } 22 | 23 | /** 24 | * Enhance contrast using the GIMP algorithm: 25 | * 26 | * value = (value - 0.5) * (tan ((factor + 1) * PI/4) ) + 0.5 27 | * 28 | * @param value Current R/G/B value of the pixel. 29 | * @param factor Factor to enhance contrast by. 30 | */ 31 | static inline float enhance_contrast(float value, float factor) 32 | { 33 | U_CHAR tan_pos = (U_CHAR) (factor * 127 + 127); 34 | return (value - 0.5) * (TAN[tan_pos]) + 0.5; 35 | } 36 | 37 | /** 38 | * Enhance brightness and contrast of an image. 39 | * 40 | * @param data Image pixel data to update. 41 | * @param size Total size of the data. 42 | * @param brightness Factor to enhance brightness by. 43 | * @param contrast Factor to enhance contrast by. 44 | */ 45 | static void enhance_bc_c(U_CHAR* data, const int size, float brightness, float contrast) 46 | { 47 | float value; 48 | 49 | for (int pixel = 0; pixel < size; pixel++) { 50 | /* Skip alpha channel */ 51 | if (pixel % 4 != ALPHA_CHANNEL) { 52 | value = ((float) data[pixel]) / 255.; 53 | value = enhance_brightness(value, brightness); 54 | value = enhance_contrast(value, contrast); 55 | value = pixel_value(value); 56 | data[pixel] = value; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /c-extension/definitions.h: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * C extension for vimiv 3 | * definitions usable for more modules. 4 | *******************************************************************************/ 5 | #ifndef definitions_h__ 6 | #define definitions_h__ 7 | 8 | /***************************************** 9 | * Alpha channel depends on endianness * 10 | *****************************************/ 11 | #if G_BYTE_ORDER == G_LITTLE_ENDIAN /* BGRA */ 12 | 13 | #define ALPHA_CHANNEL 3 14 | #define R_CHANNEL 2 15 | #define G_CHANNEL 1 16 | #define B_CHANNEL 0 17 | 18 | #elif G_BYTE_ORDER == G_BIG_ENDIAN /* ARGB */ 19 | 20 | #define ALPHA_CHANNEL 0 21 | #define R_CHANNEL 1 22 | #define G_CHANNEL 2 23 | #define B_CHANNEL 3 24 | 25 | #else /* PDP endianness: RABG */ 26 | 27 | #define ALPHA_CHANNEL 1 28 | #define R_CHANNEL 0 29 | #define G_CHANNEL 2 30 | #define B_CHANNEL 3 31 | 32 | #endif 33 | 34 | /************* 35 | * Typedefs * 36 | *************/ 37 | typedef unsigned short U_SHORT; 38 | typedef unsigned char U_CHAR; 39 | 40 | #endif // ifndef definitions_h__ 41 | -------------------------------------------------------------------------------- /c-extension/helper_func.h: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * C extension for vimiv 3 | * Small inline helper functions 4 | *******************************************************************************/ 5 | #ifndef helper_func_h__ 6 | #define helper_func_h__ 7 | 8 | #include "definitions.h" 9 | 10 | /** 11 | * Return the minimum of two numbers. 12 | */ 13 | inline float min2(float a, float b) { 14 | return a < b ? a : b; 15 | } 16 | 17 | /** 18 | * Return the maximum of two numbers. 19 | */ 20 | inline float max2(float a, float b) { 21 | return a > b ? a : b; 22 | } 23 | 24 | /** 25 | * Return the minimum of three numbers. 26 | */ 27 | inline float min3(float a, float b, float c) { 28 | if (a <= b && a <= c) 29 | return a; 30 | else if (b <= c) 31 | return b; 32 | return c; 33 | } 34 | 35 | /** 36 | * Return the maximum of three numbers. 37 | */ 38 | inline float max3(float a, float b, float c) { 39 | if (a >= b && a >= c) 40 | return a; 41 | else if (b >= c) 42 | return b; 43 | return c; 44 | } 45 | 46 | /** 47 | * Ensure a number stays within lower and upper. 48 | */ 49 | inline float clamp(float value, float lower, float upper) { 50 | if (value < lower) 51 | return lower; 52 | if (value > upper) 53 | return upper; 54 | return value; 55 | } 56 | 57 | /** 58 | * Return the remainder of a floating point division. 59 | */ 60 | inline float remainder_fl(float dividend, float divisor) { 61 | int intdiv = dividend / divisor; 62 | return dividend - intdiv * divisor; 63 | } 64 | 65 | /** 66 | * Return a valid pixel value (0..255) from a floating point value (0..1). 67 | */ 68 | static inline U_CHAR pixel_value(float value) { 69 | return (U_CHAR) clamp(value * 255, 0, 255); 70 | } 71 | 72 | #endif // ifndef helper_func_h__ 73 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = vimiv 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | html[data-theme="light"] { 2 | --pst-color-primary: #3F8DB4; 3 | --pst-color-info: #3F8DB4; 4 | 5 | --pst-color-secondary: #7AACBC; 6 | --pst-color-link-hover: #7AACBC; 7 | 8 | --pst-color-warning: #c79537; 9 | --pst-color-success: #4aa56f; 10 | --pst-color-attention: #f44336; 11 | --pst-color-danger: #f44336; 12 | 13 | --pst-color-background: rgb(244, 243, 245); 14 | --pst-color-on-background: rgb(244, 243, 245); 15 | --pst-color-surface: rgb(234, 233, 235); 16 | --pst-color-on-surface: rgb(214, 213, 215); 17 | 18 | --pst-color-text-base: #1F1D21; 19 | } 20 | 21 | html[data-theme="dark"] { 22 | --pst-color-secondary: #9FE2F6; 23 | --pst-color-link-hover: #9FE2F6; 24 | 25 | --pst-color-primary: #89C3D4; 26 | --pst-color-info: #89C3D4; 27 | 28 | --pst-color-warning: #c79537; 29 | --pst-color-success: #4aa56f; 30 | --pst-color-attention: #f44336; 31 | --pst-color-danger: #f44336; 32 | 33 | --pst-color-background: #1F1D21; 34 | --pst-color-on-background: #2b292d; 35 | --pst-color-surface: #2e2c30; 36 | --pst-color-on-surface: #444246; 37 | 38 | --pst-color-text-base: #F4F3F6; 39 | --pst-color-text-muted: rgb(208, 204, 212) 40 | } 41 | 42 | .bordered-image-light img { 43 | border: 3px solid #3F8DB4; 44 | margin: 4px; 45 | margin-top: 0px; 46 | margin-bottom: 16px; 47 | } 48 | 49 | .bordered-image-dark img { 50 | border: 3px solid #7AACBC; 51 | margin: 4px; 52 | margin-top: 0px; 53 | margin-bottom: 16px; 54 | } 55 | -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/favicon.ico -------------------------------------------------------------------------------- /docs/_static/scrots/command_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/command_dark.png -------------------------------------------------------------------------------- /docs/_static/scrots/command_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/command_light.png -------------------------------------------------------------------------------- /docs/_static/scrots/image_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/image_dark.png -------------------------------------------------------------------------------- /docs/_static/scrots/image_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/image_light.png -------------------------------------------------------------------------------- /docs/_static/scrots/library_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/library_dark.png -------------------------------------------------------------------------------- /docs/_static/scrots/library_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/library_light.png -------------------------------------------------------------------------------- /docs/_static/scrots/manipulate_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/manipulate_dark.png -------------------------------------------------------------------------------- /docs/_static/scrots/manipulate_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/manipulate_light.png -------------------------------------------------------------------------------- /docs/_static/scrots/thumbnail_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/thumbnail_dark.png -------------------------------------------------------------------------------- /docs/_static/scrots/thumbnail_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/scrots/thumbnail_light.png -------------------------------------------------------------------------------- /docs/_static/vimiv/vimiv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/vimiv/vimiv.png -------------------------------------------------------------------------------- /docs/_static/vimiv/vimiv_banner_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/vimiv/vimiv_banner_800.png -------------------------------------------------------------------------------- /docs/_static/vimiv/vimiv_banner_darkmode_800.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/docs/_static/vimiv/vimiv_banner_darkmode_800.png -------------------------------------------------------------------------------- /docs/description.rst: -------------------------------------------------------------------------------- 1 | Vimiv is an image viewer with vim-like keybindings. It is written in python3 2 | using the Qt5 toolkit and is free software, licensed under the GPL. Some of the 3 | features are: 4 | 5 | * Simple library browser 6 | * Thumbnail mode 7 | * Basic image editing 8 | * Command line with tab completion 9 | * Complete customization with style sheets 10 | -------------------------------------------------------------------------------- /docs/documentation/cl_options/index.rst: -------------------------------------------------------------------------------- 1 | Command Line Arguments 2 | ====================== 3 | 4 | When starting vimiv form the command line you have the ability to pass a number of 5 | different argument to vimiv. 6 | 7 | Examples 8 | -------- 9 | 10 | In the following we present a few use cases of command line arguments. 11 | 12 | * Start in library view with the thumbnail grid displayed:: 13 | 14 | vimiv * --command "enter thumbnail" --command "enter library" 15 | 16 | * Start in read-only mode. This prevents accidental modification (renaming, moving, editing etc.) of any images:: 17 | 18 | vimiv --set read_only true 19 | 20 | * Change WM_CLASS_INSTANCE to identify a vimiv instance:: 21 | 22 | vimiv --qt-args "--name myVimivInstance" 23 | 24 | * Print the last selected image to STDOUT when quitting:: 25 | 26 | vimiv --output "%" 27 | 28 | * Use vimiv as *Rofi for Images* to make a selection from candidate images:: 29 | 30 | mySel=$(echo $myCand | vimiv --input --output "%m" --command "enter thumbnail") 31 | 32 | * Print debug logs of your amazing plugin you are writing and of the `api._mark` module which does not behave as you are expecting:: 33 | 34 | vimiv --debug myAmazingPlugin api._mark 35 | 36 | * Grab image from the web and open it:: 37 | 38 | curl https://i.imgur.com/somefile.png | vimiv - 39 | 40 | Command Line Arguments 41 | ---------------------- 42 | 43 | The general calling structure is: 44 | 45 | .. include:: synopsis.rstsrc 46 | 47 | The following is an exhaustive list of all available arguments: 48 | 49 | .. include:: options.rstsrc 50 | -------------------------------------------------------------------------------- /docs/documentation/commands.rst: -------------------------------------------------------------------------------- 1 | .. _commands: 2 | 3 | ######## 4 | Commands 5 | ######## 6 | 7 | In vimiv all keybindings are mapped to commands. 8 | 9 | Most of the commands can also be run from the command line but some are hidden 10 | as they are not useful to run by hand. A typical example is the ``:command`` 11 | command which enters the command line. 12 | 13 | Every command is limited to its mode. This allows commands with equal names to 14 | do different things depending on the mode they are run in. For example the 15 | ``:zoom`` command rescales the image in ``image`` mode but changes the size of 16 | thumbnails in ``thumbnail`` mode. This also prevents running commands that are 17 | useless in the current mode. Commands available in the special ``global`` mode 18 | can be run in ``image``, ``library`` and ``thumbnail`` mode. 19 | 20 | Below is a complete list of all commands in every mode. 21 | 22 | .. include:: commands_desc.rstsrc 23 | -------------------------------------------------------------------------------- /docs/documentation/configuration/index.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | The following documents are available: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | settings 10 | keybindings 11 | statusbar 12 | style 13 | plugins 14 | -------------------------------------------------------------------------------- /docs/documentation/configuration/statusbar.rst: -------------------------------------------------------------------------------- 1 | .. _statusbar: 2 | 3 | ######### 4 | Statusbar 5 | ######### 6 | 7 | In vimiv a statusbar at the bottom is used to display useful information. 8 | 9 | The content of the statusbar is completely configurable and mode dependent. The 10 | bar is grouped into three parts: left, center and right. Each part has a 11 | fallback content and can have a mode-dependent content which overrides the 12 | default. The content is defined in the ``STATUSBAR`` section of the 13 | ``vimiv.conf`` configuration file located in ``$XDG_CONFIG_HOME/vimiv/`` where 14 | ``$XDG_CONFIG_HOME`` is usually ``~/.config/`` if you have not configured it. 15 | 16 | The three default options are defined via: 17 | 18 | * ``left = the content to display on the left`` 19 | * ``center = the content to display in the center`` 20 | * ``right = the content to display on the right`` 21 | 22 | And can be extended by adding options in the form of e.g. 23 | 24 | * ``left_image = content to display on the left in image mode`` 25 | * ``center_thumbnail = content to display in the center in thumbnail mode`` 26 | 27 | Text you enter is displayed *as is*. Formatting using a subset of the html 28 | styles is possible. As this is not very useful, vimiv provides a set of so 29 | called ``modules`` for the statusbar. A module is indicated by surrounding its 30 | name in curly braces, e.g. ``{pwd}``. This gets replaced by the output of the 31 | module. A list of available modules can be found 32 | :ref:`in the table below `. 33 | 34 | You can find information on the supported html subset 35 | `in the Qt documentation `_. Some 36 | useful examples: 37 | 38 | * ``bold text`` 39 | * ``italic text`` 40 | * ``underlined text`` 41 | * ``red text`` 42 | * ``fancy html-cyan text`` 43 | * ``red background`` 44 | 45 | .. _status_modules: 46 | 47 | .. include:: status_modules.rstsrc 48 | -------------------------------------------------------------------------------- /docs/documentation/configuration/style.rst: -------------------------------------------------------------------------------- 1 | .. _styles: 2 | 3 | Style 4 | ===== 5 | There are two default styles: a light and a dark one. They are both based upon 6 | `base16 tomorrow `_, 7 | the dark one using tomorrow night. To switch between them, set the ``style`` 8 | setting in the ``vimiv.conf`` file to ``default`` or ``default-dark`` 9 | respectively. 10 | 11 | The style files of the default styles are written to the ``styles`` directory 12 | on startup if they do not exist. The styles directory is located in 13 | ``$XDG_CONFIG_HOME/vimiv/`` where ``$XDG_CONFIG_HOME`` is usually 14 | ``~/.config/`` if you have not updated it. 15 | 16 | A bunch of base16 styles to pick from are available in the 17 | `base16-vimiv repository `_. 18 | 19 | Creating your own style is easy: 20 | 21 | #. Create a new file in the ``styles`` directory. The file must start with the 22 | ``[STYLE]`` header. 23 | #. Define the colors ``base00`` to ``base0f``. This is required as these colors are 24 | referenced by the individual options. 25 | #. Change the ``style`` setting in the ``vimiv.conf`` file to the name of your newly 26 | created file. 27 | #. Optional: override any other option such as the global ``font`` or individual 28 | settings like ``thumbnail.padding``. 29 | 30 | .. hint:: Refer to the created default style for all available options 31 | 32 | .. hint:: Defined style options can be referenced via ``new_option = {other_option}``, 33 | for example to use a different base color for mark-related things you can use 34 | ``mark.color = {base0a}``. 35 | 36 | .. hint:: As for settings, you can refer to 37 | :ref:`external resources `. 38 | 39 | .. hint:: The python configparser module is not case sensitive. Therefore it is 40 | a good idea to keep all your style options in lower case. 41 | -------------------------------------------------------------------------------- /docs/documentation/contributing.rst: -------------------------------------------------------------------------------- 1 | .. _contributing: 2 | 3 | .. include:: ../../.github/CONTRIBUTING.rst 4 | -------------------------------------------------------------------------------- /docs/documentation/contributing_bugs.rst: -------------------------------------------------------------------------------- 1 | Contributing / Reporting Bugs 2 | ----------------------------- 3 | 4 | You want to contribute to vimiv? Great! Feel free to take a look at the 5 | :ref:`contributing` for more details and a few tips on how to get 6 | started. 7 | 8 | The best way to report bugs is to open an 9 | `issue on github `_. If you do 10 | not have a github account, feel free to 11 | `contact me directly `_. If possible, please reproduce 12 | the bug running ``vimiv --log-level debug`` and include the log file located in 13 | ``$XDG_DATA_HOME/vimiv/vimiv.log`` where ``$XDG_DATA_HOME`` is usually 14 | ``~/.local/share/`` if you have not configured it. 15 | -------------------------------------------------------------------------------- /docs/documentation/datafile_warning.rst: -------------------------------------------------------------------------------- 1 | .. warning:: 2 | 3 | This does not install data files such as the icons or the ``vimiv.desktop`` 4 | file globally. Thus, e.g., file managers may not find the vimiv program as 5 | expected. To get an idea on how to install these, you can take a look at 6 | the Makefile located in `misc/Makefile` and read the section on 7 | :ref:`system-wide installation `. 8 | -------------------------------------------------------------------------------- /docs/documentation/dependency_info.rst: -------------------------------------------------------------------------------- 1 | .. note:: 2 | 3 | You need to have the :ref:`dependencies ` installed 4 | system-wide for this to work. 5 | -------------------------------------------------------------------------------- /docs/documentation/getting_help.rst: -------------------------------------------------------------------------------- 1 | Getting Help 2 | ------------ 3 | 4 | You can contact me under `my email address `_ or 5 | open an `issue on github `_ if you 6 | think the question is of general interest. 7 | -------------------------------------------------------------------------------- /docs/documentation/index.rst: -------------------------------------------------------------------------------- 1 | Docs 2 | ==== 3 | 4 | The following documents are available: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | install 10 | getting_started 11 | commands 12 | configuration/index 13 | cl_options/index 14 | metadata 15 | contributing 16 | migrating 17 | 18 | .. include:: getting_help.rst 19 | 20 | .. include:: contributing_bugs.rst 21 | -------------------------------------------------------------------------------- /docs/documentation/updating_icon_cache.rst: -------------------------------------------------------------------------------- 1 | .. note:: 2 | 3 | You may need to run ``gtk-update-icon-cache /usr/share/icons/hicolor`` (replace the 4 | path if installing the icons somewhere else) after the install to get the vimiv icon 5 | in your application launcher. It has been reported that the command was needed for 6 | `wofi `_. 7 | -------------------------------------------------------------------------------- /docs/manpage/vimiv.1.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | ************** 4 | Vimiv man page 5 | ************** 6 | 7 | Synopsis 8 | """""""" 9 | 10 | .. include:: synopsis.rstsrc 11 | 12 | Description 13 | """"""""""" 14 | 15 | .. include:: /description.rst 16 | 17 | A much more complete documentation can be found under 18 | https://karlch.github.io/vimiv-qt/. 19 | 20 | Options 21 | """"""" 22 | 23 | .. include:: options.rstsrc 24 | 25 | Website 26 | """"""" 27 | https://karlch.github.io/vimiv-qt/ 28 | -------------------------------------------------------------------------------- /docs/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = alabaster 3 | stylesheet = style.css 4 | pygments_style = pygments.css 5 | -------------------------------------------------------------------------------- /icons/vimiv_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/icons/vimiv_128x128.png -------------------------------------------------------------------------------- /icons/vimiv_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/icons/vimiv_16x16.png -------------------------------------------------------------------------------- /icons/vimiv_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/icons/vimiv_256x256.png -------------------------------------------------------------------------------- /icons/vimiv_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/icons/vimiv_32x32.png -------------------------------------------------------------------------------- /icons/vimiv_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/icons/vimiv_512x512.png -------------------------------------------------------------------------------- /icons/vimiv_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/icons/vimiv_64x64.png -------------------------------------------------------------------------------- /misc/Makefile: -------------------------------------------------------------------------------- 1 | PREFIX := /usr 2 | DATADIR := $(DESTDIR)/$(PREFIX)/share 3 | MANDIR := $(DATADIR)/man 4 | APPDIR := $(DATADIR)/applications 5 | LICENSEDIR := $(DATADIR)/licenses 6 | METAINFODIR := $(DATADIR)/metainfo 7 | 8 | ICONSIZES := 16 32 64 128 256 512 9 | 10 | default: 11 | @printf "There is nothing to do.\n" 12 | @printf "Run 'sudo make install' to install vimiv.\n" 13 | @printf "Run 'make options' for a list of all options.\n" 14 | 15 | options: help 16 | @printf "\nOptions:\n" 17 | @printf "DESTDIR = $(DESTDIR)/\n" 18 | @printf "PREFIX = $(PREFIX)\n" 19 | @printf "DATADIR = $(DATADIR)\n" 20 | @printf "MANDIR = $(MANDIR)\n" 21 | @printf "LICENSEDIR = $(LICENSEDIR)\n" 22 | @printf "METAINFODIR = $(METAINFODIR)\n" 23 | 24 | help: 25 | @printf "make help: Print help.\n" 26 | @printf "make options: Print help and list all options.\n" 27 | @printf "make install: Install vimiv.\n" 28 | @printf "make uninstall: Uninstall vimiv.\n" 29 | @printf "make clean: Remove build directories.\n" 30 | 31 | install: 32 | python3 setup.py install --root=$(DESTDIR)/ --prefix=$(PREFIX) --record=install_log.txt 33 | install -Dm644 misc/vimiv.desktop $(APPDIR)/vimiv.desktop 34 | install -Dm644 misc/org.karlch.vimiv.qt.metainfo.xml $(METAINFODIR)/org.karlch.vimiv.qt.metainfo.xml 35 | install -Dm644 LICENSE $(LICENSEDIR)/vimiv/LICENSE 36 | install -Dm644 misc/vimiv.1 $(MANDIR)/man1/vimiv.1 37 | gzip -n -9 -f $(MANDIR)/man1/vimiv.1 38 | $(foreach i,$(ICONSIZES),install -Dm644 icons/vimiv_${i}x${i}.png $(DATADIR)/icons/hicolor/${i}x${i}/apps/vimiv.png;) 39 | install -Dm644 icons/vimiv.svg $(DATADIR)/icons/hicolor/scalable/apps/vimiv.svg 40 | 41 | uninstall: 42 | scripts/uninstall_pythonpkg.sh 43 | rm $(APPDIR)/vimiv.desktop 44 | rm $(METAINFODIR)/org.karlch.vimiv.qt.metainfo.xml 45 | rm -r $(LICENSEDIR)/vimiv 46 | rm $(MANDIR)/man1/vimiv.1.gz 47 | $(foreach i,$(ICONSIZES),rm $(DATADIR)/icons/hicolor/${i}x${i}/apps/vimiv.png;) 48 | rm $(DATADIR)/icons/hicolor/scalable/apps/vimiv.svg 49 | 50 | clean: 51 | rm -rf build vimiv.egg-info/ 52 | -------------------------------------------------------------------------------- /misc/requirements/requirements_cov.txt: -------------------------------------------------------------------------------- 1 | coverage==7.5.1 2 | pytest-cov==5.0.0 3 | -------------------------------------------------------------------------------- /misc/requirements/requirements_docs.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.3.7 2 | pydata-sphinx-theme==0.15.2 3 | sphinxcontrib-images==0.9.4 4 | -------------------------------------------------------------------------------- /misc/requirements/requirements_lint.txt: -------------------------------------------------------------------------------- 1 | pylint==3.1.0 2 | black==24.4.2 3 | pydocstyle==6.3.0 4 | -------------------------------------------------------------------------------- /misc/requirements/requirements_mypy.txt: -------------------------------------------------------------------------------- 1 | mypy==1.10.0 2 | PyQt5-stubs==5.15.6.0 3 | -------------------------------------------------------------------------------- /misc/requirements/requirements_packaging.txt: -------------------------------------------------------------------------------- 1 | pyroma==4.2 2 | check-manifest==0.49 3 | -------------------------------------------------------------------------------- /misc/requirements/requirements_piexif.txt: -------------------------------------------------------------------------------- 1 | piexif==1.1.3 2 | -------------------------------------------------------------------------------- /misc/requirements/requirements_pyexiv2.txt: -------------------------------------------------------------------------------- 1 | py3exiv2==0.11.0 2 | -------------------------------------------------------------------------------- /misc/requirements/requirements_pyqt5.txt: -------------------------------------------------------------------------------- 1 | PyQt5==5.15.10 2 | -------------------------------------------------------------------------------- /misc/requirements/requirements_pyqt6.txt: -------------------------------------------------------------------------------- 1 | PyQt6==6.7.0 2 | -------------------------------------------------------------------------------- /misc/requirements/requirements_pyside6.txt: -------------------------------------------------------------------------------- 1 | PySide6==6.7.0 2 | -------------------------------------------------------------------------------- /misc/requirements/requirements_setup.txt: -------------------------------------------------------------------------------- 1 | setuptools>=40.8.0 2 | -------------------------------------------------------------------------------- /misc/requirements/requirements_tests.txt: -------------------------------------------------------------------------------- 1 | flaky==3.8.1 2 | pytest==8.2.0 3 | pytest-mock==3.14.0 4 | pytest-qt==4.4.0 5 | pytest-xvfb==3.0.0 6 | pytest-bdd==7.1.2 7 | -------------------------------------------------------------------------------- /misc/requirements/requirements_tox.txt: -------------------------------------------------------------------------------- 1 | tox==4.15.0 2 | -------------------------------------------------------------------------------- /misc/vimiv.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Name=vimiv 4 | GenericName=Image Viewer 5 | Comment=An image viewer with vim like keybindings 6 | Icon=vimiv 7 | Terminal=false 8 | Exec=vimiv %F 9 | Categories=Graphics; 10 | MimeType=image/bmp;image/gif;image/icns;image/jp2;image/jpeg;image/jpeg2000;image/jpx;image/png;image/svg;image/tiff;image/webp;image/x-portable-anymap;image/x-portable-bitmap;image/x-portable-graymap;image/x-portable-greymap;image/x-portable-pixmap;image/x-tga;image/x-xbitmap;image/x-xpixmap; 11 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | warn_redundant_casts = True 4 | warn_unused_ignores = True 5 | show_error_codes = True 6 | pretty = True 7 | implicit_optional = True 8 | 9 | [mypy-piexif] 10 | ignore_missing_imports = True 11 | 12 | [mypy-PyQt5.QtSvg] 13 | ignore_missing_imports = True 14 | 15 | [mypy-PyQt5.sip] 16 | ignore_missing_imports = True 17 | 18 | [mypy-vimiv.app] 19 | disallow_untyped_defs = True 20 | disallow_incomplete_defs = True 21 | 22 | [mypy-vimiv.parser] 23 | disallow_untyped_defs = True 24 | disallow_incomplete_defs = True 25 | 26 | [mypy-vimiv.startup] 27 | disallow_untyped_defs = True 28 | disallow_incomplete_defs = True 29 | 30 | [mypy-vimiv.version] 31 | disallow_untyped_defs = True 32 | disallow_incomplete_defs = True 33 | 34 | [mypy-vimiv.api.*] 35 | disallow_untyped_defs = True 36 | disallow_incomplete_defs = True 37 | 38 | [mypy-vimiv.plugins.*] 39 | disallow_untyped_defs = True 40 | disallow_incomplete_defs = True 41 | 42 | [mypy-vimiv.utils.*] 43 | disallow_untyped_defs = True 44 | disallow_incomplete_defs = True 45 | 46 | [mypy-vimiv.utils] 47 | disallow_untyped_defs = False 48 | disallow_incomplete_defs = False 49 | 50 | # TODO re-think qt type checking 51 | [mypy-vimiv.qt.*] 52 | ignore_errors = True 53 | 54 | [mypy-vimiv.qt] 55 | ignore_errors = True 56 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | addopts = --no-flaky-report --strict-markers 4 | faulthandler_timeout = 30 5 | markers = 6 | current: Mark tests during development 7 | imageformats: Require retrieving images from the web to test additional formats 8 | metadata: Require metadata support 9 | piexif: Require piexif 10 | pyexiv2: Require pyexiv2 11 | nometadata: Requires metadata support NOT to be available 12 | ci: Run test only on ci 13 | ci_skip: Skip test on ci 14 | -------------------------------------------------------------------------------- /scripts/gen_manpage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Simple script to create the man page using sphinx 4 | 5 | # First re-create the source code documentation 6 | scripts/src2rst.py 7 | 8 | # Then call sphinx to rebuild the man page 9 | sphinx-build -b man docs misc/ 10 | -------------------------------------------------------------------------------- /scripts/lint_tests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 3 | 4 | """Script to run pylint over the test-suite. 5 | 6 | Required due to https://github.com/PyCQA/pylint/issues/352. 7 | """ 8 | 9 | import argparse 10 | import os 11 | import sys 12 | import subprocess 13 | from typing import List 14 | 15 | 16 | def get_parser() -> argparse.ArgumentParser: 17 | parser = argparse.ArgumentParser() 18 | parser.add_argument("directory") 19 | return parser 20 | 21 | 22 | def get_all_python_files(directory: str) -> List[str]: 23 | """Retrieve all files in a directory by walking it.""" 24 | infiles = [ 25 | os.path.join(dirpath, filename) 26 | for dirpath, _, filenames in os.walk(directory) 27 | for filename in filenames 28 | if os.path.splitext(filename)[-1] == ".py" 29 | ] 30 | return infiles 31 | 32 | 33 | def run_pylint(infiles: List[str]): 34 | """Run pylint over all files in infiles.""" 35 | disabled = ( 36 | "redefined-outer-name", # Fixture passed as argument 37 | "unused-argument", # Fixture used for setup / teardown only 38 | "missing-docstring", # Not required in tests 39 | "command-docstring", # Not required in tests 40 | "protected-access", # Acceptable in tests 41 | "compare-to-empty-string", # Stricter check than False in tests 42 | "import-error", # Errors on pytest related modules 43 | ) 44 | ignored = ("mock_plugin_syntax_error.py",) 45 | command = ( 46 | "pylint", 47 | f"--disable={','.join(disabled)}", 48 | f"--ignore={','.join(ignored)}", 49 | *infiles, 50 | ) 51 | print("Running pylint over tests") 52 | return subprocess.run(command, check=False).returncode 53 | 54 | 55 | def main(): 56 | parser = get_parser() 57 | args = parser.parse_args() 58 | infiles = get_all_python_files(args.directory) 59 | sys.exit(run_pylint(infiles)) 60 | 61 | 62 | if __name__ == "__main__": 63 | main() 64 | -------------------------------------------------------------------------------- /scripts/maybe_build_cextension.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Ensure the c-extension has been build inplace for testing. 4 | 5 | Locally, when running tox without compiling the c-extension, an ImportError for 6 | _c_manipulate may be raised. This is fixed by this script. 7 | """ 8 | 9 | import glob 10 | import os 11 | import subprocess 12 | import sys 13 | 14 | 15 | if __name__ == "__main__": 16 | filedir = os.path.dirname(os.path.realpath(__file__)) 17 | rootdir = os.path.dirname(filedir) 18 | extension_built = glob.glob(os.path.join(rootdir, "vimiv", "imutils", "_c_*")) 19 | 20 | if not extension_built: 21 | print("Building c-extension...") 22 | setup_py = os.path.join(rootdir, "setup.py") 23 | cmd = sys.executable, setup_py, "build_ext", "--inplace" 24 | subprocess.run(cmd, check=True) 25 | -------------------------------------------------------------------------------- /scripts/pylint_checkers/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Custom checkers for pylint.""" 4 | -------------------------------------------------------------------------------- /scripts/pylint_checkers/check_header.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Checker to ensure each python file includes a modeline and no copyright notice.""" 4 | 5 | from pylint.checkers import BaseChecker 6 | 7 | 8 | class FileHeaderChecker(BaseChecker): 9 | """Checker to ensure each python file includes a modeline and copyright notice.""" 10 | 11 | name = "file-header" 12 | name_modeline_missing = "modeline-missing" 13 | name_copyright_included = "copyright-included" 14 | 15 | msgs = { 16 | "E9501": ( 17 | "Vim modeline is missing", 18 | name_modeline_missing, 19 | "All files should include a valid vim modeline.", 20 | ), 21 | "E9502": ( 22 | "Copyright included", 23 | name_copyright_included, 24 | "There should be no copyright at the top of the file.", 25 | ), 26 | } 27 | options = () 28 | 29 | priority = -1 30 | 31 | MODELINE = "# vim: ft=python fileencoding=utf-8 sw=4 et sts=4" 32 | 33 | def process_module(self, node): 34 | """Read the module content as string and check for the necessary content.""" 35 | 36 | with node.stream() as stream: 37 | content = stream.read().decode("utf-8") 38 | 39 | lines = content.split("\n") 40 | 41 | if self.MODELINE not in lines: 42 | self.add_message(self.name_modeline_missing, line=1) 43 | 44 | if any(line.lower().startswith("# copyright") for line in lines): 45 | self.add_message(self.name_copyright_included, line=1) 46 | 47 | 48 | def register(linter): 49 | """Register the defined checkers automatically.""" 50 | linter.register_checker(FileHeaderChecker(linter)) 51 | -------------------------------------------------------------------------------- /scripts/uninstall_pythonpkg.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Remove the installed python package from /usr/lib/python3.x/site-packages 4 | 5 | if [[ -f install_log.txt ]]; then 6 | rm $(awk '{print "'/'"$0}' install_log.txt) 7 | else 8 | printf "python-setuptools does not provide an uninstall option.\n" 9 | printf "To completely remove vimiv you will have to remove all related" 10 | printf " files from /usr/lib/python3.x/site-packages/.\n" 11 | printf "A list of files should have been generated during make install" 12 | printf " in install_log.txt but seems to have been removed.\n" 13 | fi 14 | -------------------------------------------------------------------------------- /scripts/vimiv_history.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 3 | 4 | """Script to print the vimiv history of a given mode. 5 | 6 | Reads the history from vimiv's json history file and prints the elements 7 | line-by-line. The default mode is image. To change the mode, pass it using its 8 | name. 9 | 10 | In case you use a custom data directory or are not using linux, please pass the 11 | filename as argument. 12 | """ 13 | 14 | import argparse 15 | import json 16 | import os 17 | from typing import List 18 | 19 | 20 | def main(): 21 | parser = get_parser() 22 | args = parser.parse_args() 23 | history = read_history(mode=args.mode, filename=args.filename) 24 | print(*history, sep="\n") 25 | 26 | 27 | def get_parser() -> argparse.ArgumentParser: 28 | parser = argparse.ArgumentParser( 29 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter 30 | ) 31 | parser.add_argument( 32 | "mode", 33 | choices=("image", "thumbnail", "library", "manipulate"), 34 | default="image", 35 | nargs="?", 36 | help="The mode for which history is printed", 37 | ) 38 | parser.add_argument( 39 | "-f", 40 | "--filename", 41 | default=os.path.expanduser("~/.local/share/vimiv/history.json"), 42 | help="Path to the history file to read", 43 | ) 44 | return parser 45 | 46 | 47 | def read_history(*, mode: str, filename: str) -> List[str]: 48 | with open(filename, "r", encoding="utf-8") as f: 49 | content = json.load(f) 50 | return content[mode] 51 | 52 | 53 | if __name__ == "__main__": 54 | main() 55 | -------------------------------------------------------------------------------- /tests/end2end/features/api/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import os 4 | 5 | import pytest_bdd as bdd 6 | 7 | from vimiv import api 8 | 9 | 10 | @bdd.then(bdd.parsers.parse("there should be {n_marked:d} marked images")) 11 | def check_number_marked(n_marked): 12 | assert len(api.mark.paths) == n_marked 13 | 14 | 15 | @bdd.then(bdd.parsers.parse("{path} should be marked")) 16 | def check_image_marked(path): 17 | assert path in [os.path.basename(p) for p in api.mark.paths] 18 | 19 | 20 | @bdd.then(bdd.parsers.parse("{path} should not be marked")) 21 | def check_image_not_marked(path): 22 | assert path not in [os.path.basename(p) for p in api.mark.paths] 23 | -------------------------------------------------------------------------------- /tests/end2end/features/api/keybindings.feature: -------------------------------------------------------------------------------- 1 | Feature: Bind and unbind keybindings. 2 | 3 | Scenario: Bind in library mode 4 | Given I open any directory 5 | When I run bind ZX test 6 | Then the keybinding ZX should exist for mode library 7 | 8 | Scenario: Bind in global mode 9 | Given I open any directory 10 | When I run bind ZX test --mode=global 11 | Then the keybinding ZX should exist for mode image 12 | And the keybinding ZX should exist for mode library 13 | And the keybinding ZX should exist for mode thumbnail 14 | 15 | Scenario: Bind and unbind command 16 | Given I open any directory 17 | When I run bind ZX test 18 | And I run unbind ZX 19 | Then the keybinding ZX should not exist for mode library 20 | -------------------------------------------------------------------------------- /tests/end2end/features/api/print.feature: -------------------------------------------------------------------------------- 1 | Feature: Print to STDOUT 2 | 3 | Scenario: Print trivial list 4 | Given I start vimiv 5 | And I capture output 6 | When I run print-stdout "test" 7 | Then stdout should contain 'test' 8 | 9 | Scenario: Print longer list 10 | Given I start vimiv 11 | And I capture output 12 | When I run print-stdout "test1" "test2" 13 | Then stdout should contain 'test1' 14 | And stdout should contain 'test2' 15 | 16 | Scenario: Print list using sep 17 | Given I start vimiv 18 | And I capture output 19 | When I run print-stdout "test1" "test2" --sep ',' 20 | Then stdout should contain 'test1,test2' 21 | 22 | Scenario: Print list using end 23 | Given I start vimiv 24 | And I capture output 25 | When I run print-stdout "test1" "test2" --end 'xxx\n' 26 | Then stdout should contain 'test2xxx' 27 | 28 | Scenario: Print marked images 29 | Given I open 2 images 30 | And I capture output 31 | When I run mark image_01.jpg image_02.jpg 32 | And I run print-stdout %m 33 | Then stdout should contain 'image_01.jpg' 34 | And stdout should contain 'image_02.jpg' 35 | 36 | Scenario: Print marked images using alias 37 | Given I open 2 images 38 | And I capture output 39 | When I run mark image_01.jpg image_02.jpg 40 | And I run mark-print 41 | Then stdout should contain 'image_01.jpg' 42 | And stdout should contain 'image_02.jpg' 43 | -------------------------------------------------------------------------------- /tests/end2end/features/api/prompt.feature: -------------------------------------------------------------------------------- 1 | Feature: Prompt the user for a question 2 | 3 | Scenario Outline: Ask a question and answer it 4 | Given I open any directory 5 | And I ask a question and answer with 6 | Then I expect as answer 7 | 8 | Examples: 9 | | key | answer | 10 | | y | true | 11 | | n | false | 12 | | | false | 13 | | | none | 14 | -------------------------------------------------------------------------------- /tests/end2end/features/api/rename.feature: -------------------------------------------------------------------------------- 1 | Feature: Rename paths 2 | 3 | Scenario: Rename images 4 | Given I open 2 images 5 | When I run rename * new_name 6 | Then the file new_name_001.jpg should exist 7 | Then the file new_name_002.jpg should exist 8 | 9 | Scenario: Keep mark state when renaming images 10 | Given I open 2 images 11 | When I run mark % 12 | When I run rename * new_name 13 | Then the file new_name_001.jpg should exist 14 | And new_name_001.jpg should be marked 15 | 16 | Scenario: Do not rename directories 17 | Given I open a directory with 2 paths 18 | When I run rename * new_directory 19 | Then no crash should happen 20 | And the directory new_directory_001 should not exist 21 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_keybindings_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv import api 7 | 8 | 9 | bdd.scenarios("keybindings.feature") 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def cleanup_keybindings(cleanup_helper): 14 | """Fixture to delete any keybindings that were created during testing.""" 15 | yield from cleanup_helper(api.keybindings._registry) 16 | 17 | 18 | @bdd.then(bdd.parsers.parse("the keybinding {binding} should exist for mode {mode}")) 19 | def keybinding_exists(binding, mode): 20 | mode = api.modes.get_by_name(mode) 21 | assert binding in api.keybindings._registry[mode] 22 | 23 | 24 | @bdd.then( 25 | bdd.parsers.parse("the keybinding {binding} should not exist for mode {mode}") 26 | ) 27 | def keybinding_not_exists(binding, mode): 28 | mode = api.modes.get_by_name(mode) 29 | assert binding not in api.keybindings._registry[mode] 30 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_mark_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import os 4 | 5 | import pytest_bdd as bdd 6 | 7 | from vimiv.api._mark import Tag 8 | 9 | 10 | bdd.scenarios("mark.feature") 11 | 12 | 13 | @bdd.when("I remove the delete permissions") 14 | def remove_delete_permission(mocker): 15 | mocker.patch("os.remove", side_effect=PermissionError) 16 | mocker.patch("shutil.rmtree", side_effect=PermissionError) 17 | 18 | 19 | @bdd.when(bdd.parsers.parse("I create the tag '{name}' with permissions '{mode:o}'")) 20 | def create_tag_with_permission(name, mode): 21 | with Tag(name, read_only=False): 22 | pass 23 | path = Tag.path(name) 24 | os.chmod(path, 0o000) 25 | 26 | 27 | @bdd.when(bdd.parsers.parse("I insert an empty line into the tag file {name}")) 28 | def insert_empty_lines_into_tag_file(name): 29 | assert os.path.isfile(Tag.path(name)), f"Tag file {name} does not exist" 30 | with open(Tag.path(name), "a", encoding="utf-8") as f: 31 | f.write("\n") 32 | 33 | 34 | @bdd.then(bdd.parsers.parse("the tag file {name} should exist with {n_paths:d} paths")) 35 | def check_tag_file(name, n_paths): 36 | assert os.path.isfile(Tag.path(name)), f"Tag file {name} does not exist" 37 | with Tag(name, "r") as tag: 38 | paths = tag.read() 39 | assert len(paths) == n_paths 40 | 41 | 42 | @bdd.then(bdd.parsers.parse("the tag file {name} should not contain any empty lines")) 43 | def check_tag_file_no_empty_line(name): 44 | assert os.path.isfile(Tag.path(name)), f"Tag file {name} does not exist" 45 | with open(Tag.path(name), "r", encoding="utf-8") as f: 46 | assert all(line.strip() for line in f) 47 | 48 | 49 | @bdd.then(bdd.parsers.parse("the tag file {name} should not exist")) 50 | def check_tag_file_not_exists(name): 51 | assert not os.path.exists(Tag.path(name)) 52 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_modeswitch_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | 7 | 8 | bdd.scenarios("modeswitch.feature") 9 | 10 | 11 | @bdd.when(bdd.parsers.parse("I toggle {mode} mode")) 12 | def toggle_mode(mode): 13 | api.modes.get_by_name(mode).toggle() 14 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_print_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("print.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_prompt_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | 7 | 8 | bdd.scenarios("prompt.feature") 9 | 10 | 11 | @bdd.given( 12 | bdd.parsers.parse("I ask a question and answer with {key}"), 13 | target_fixture="prompt_response", 14 | ) 15 | def answer_question(answer_prompt, key): 16 | answer_prompt(key) 17 | return api.prompt.ask_question(title="end2end-question", body="Hello there?") 18 | 19 | 20 | @bdd.then(bdd.parsers.parse("I expect {answer} as answer")) 21 | def check_prompt(prompt_response, answer): 22 | answer = answer.lower() 23 | if answer in ("true", "yes", "1"): 24 | assert prompt_response 25 | elif answer in ("false", "no", "0"): 26 | assert not prompt_response 27 | elif answer == "none": 28 | assert prompt_response is None 29 | else: 30 | raise ValueError(f"Unexpected prompt answer '{answer}'") 31 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_rename_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("rename.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/api/test_working_directory_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | 7 | 8 | bdd.scenarios("working_directory.feature") 9 | 10 | 11 | @bdd.then(bdd.parsers.parse("there should be {n_directories:d} monitored directory")) 12 | @bdd.then(bdd.parsers.parse("there should be {n_directories:d} monitored directories")) 13 | def check_monitored_directories(n_directories): 14 | assert len(api.working_directory.handler.directories()) == n_directories 15 | 16 | 17 | @bdd.then(bdd.parsers.parse("there should be {n_files:d} monitored file")) 18 | @bdd.then(bdd.parsers.parse("there should be {n_files:d} monitored files")) 19 | def check_monitored_files(n_files): 20 | assert len(api.working_directory.handler.files()) == n_files 21 | -------------------------------------------------------------------------------- /tests/end2end/features/api/working_directory.feature: -------------------------------------------------------------------------------- 1 | Feature: React to working directory changes 2 | 3 | Scenario: Monitor the current directory 4 | Given I open any directory 5 | Then there should be 1 monitored directory 6 | 7 | Scenario: Unmonitor the current directory 8 | Given I open any directory 9 | When I run set monitor_filesystem false 10 | Then there should be 0 monitored directories 11 | 12 | Scenario: Monitor the current image 13 | Given I open any image 14 | Then there should be 1 monitored file 15 | 16 | Scenario: Unmonitor the current image 17 | Given I open any image 18 | When I run set monitor_filesystem false 19 | Then there should be 0 monitored files 20 | -------------------------------------------------------------------------------- /tests/end2end/features/command/aliases.feature: -------------------------------------------------------------------------------- 1 | Feature: Create and run aliases. 2 | 3 | Scenario: Create and run an alias. 4 | Given I start vimiv 5 | When I run alias mycount count 6 | And I run mycount 7 | Then the count should be 1 8 | 9 | Scenario: Create an alias and run it with arguments. 10 | Given I start vimiv 11 | When I run alias mycount count 12 | And I run mycount --number=5 13 | Then the count should be 5 14 | 15 | Scenario: Create and run an alias with arguments. 16 | Given I start vimiv 17 | When I run alias count-three 'count --number=3' 18 | And I run count-three 19 | Then the count should be 3 20 | 21 | Scenario: Do not overwrite existing commands with alias. 22 | Given I start vimiv 23 | When I run alias quit scroll 24 | Then the alias quit should not exist 25 | And the message 26 | 'alias: Not overriding default command quit' 27 | should be displayed 28 | 29 | Scenario: Alias to an external command 30 | Given I start vimiv 31 | When I run alias listdir !ls 32 | And I run bind zzz listdir 33 | And I press 'zzz' 34 | Then no message should be displayed 35 | 36 | Scenario: Alias including wildcards 37 | Given I open a directory with 1 paths 38 | When I run alias copythis '!cp -r \% other_directory' 39 | And I run copythis 40 | Then the directory other_directory should exist 41 | -------------------------------------------------------------------------------- /tests/end2end/features/command/chaining.feature: -------------------------------------------------------------------------------- 1 | Feature: Chain commands together. 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Chain two commands together. 7 | When I run count && count 8 | Then the count should be 2 9 | 10 | Scenario: Chain three commands together. 11 | When I run count && count && count 12 | Then the count should be 3 13 | 14 | Scenario: Chain commands with count together. 15 | When I run 2count && 3count 16 | Then the count should be 5 17 | 18 | Scenario: Fail first of two chained commands 19 | When I run something wrong && count 20 | Then the count should be 0 21 | And the message 22 | 'something: unknown command for mode library' 23 | should be displayed 24 | 25 | Scenario: Fail second of two chained commands 26 | When I run count && something wrong 27 | Then the count should be 1 28 | And the message 29 | 'something: unknown command for mode library' 30 | should be displayed 31 | 32 | Scenario: Fail second of three chained commands 33 | When I run count && something wrong && count 34 | Then the count should be 1 35 | And the message 36 | 'something: unknown command for mode library' 37 | should be displayed 38 | 39 | Scenario: Run an alias of chained commands 40 | When I run alias double-count count \&\& count 41 | And I run double-count 42 | # Run twice as running once also works if the alias is only aliased to the first 43 | # command and the second command is executed right after the alias command 44 | And I run double-count 45 | Then the count should be 4 46 | 47 | Scenario: Run a chain of aliases where each alias consists of a chain of commands 48 | When I run alias double-count count \&\& count 49 | When I run alias triple-count count \&\& count \&\&count 50 | And I run double-count && double-count && triple-count 51 | Then the count should be 7 52 | -------------------------------------------------------------------------------- /tests/end2end/features/command/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | 5 | from vimiv.commands import aliases 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def cleanup_aliases(cleanup_helper): 10 | """Fixture to delete any aliases that were created in this feature.""" 11 | yield from cleanup_helper(aliases._aliases) 12 | -------------------------------------------------------------------------------- /tests/end2end/features/command/expand_wildcards.feature: -------------------------------------------------------------------------------- 1 | Feature: Expand wildcards when running commands 2 | 3 | Scenario: Expand % to the current path in the library 4 | Given I open a directory with 1 paths 5 | When I run !cp -r % %_bak 6 | Then the directory child_01_bak should exist 7 | 8 | Scenario: Expand % to current image in image mode 9 | Given I open any image 10 | When I run !cp % %.bak 11 | Then the file image.jpg.bak should exist 12 | 13 | Scenario: Do not expand % when escaped 14 | Given I open a directory with 1 paths 15 | When I run !cp -r \% %_bak 16 | Then the directory child_01_bak should not exist 17 | And a message should be displayed 18 | 19 | Scenario: Expand * to the list of paths in the library 20 | Given I open a directory with 2 paths 21 | When I run !rmdir * 22 | Then the directory child_01 should not exist 23 | And the directory child_02 should not exist 24 | 25 | Scenario: Expand * to the list of images in image mode 26 | Given I open 2 images 27 | When I run !rm * 28 | Then the file image_01.jpg should not exist 29 | And the file image_02.jpg should not exist 30 | 31 | Scenario: Do not expand * when escaped 32 | Given I open a directory with 2 paths 33 | When I run !rmdir \* 34 | Then the directory child_01 should exist 35 | And the directory child_02 should exist 36 | And a message should be displayed 37 | 38 | Scenario: Expand tilde to home directory 39 | Given I open a directory with 1 paths 40 | When I run !cp -r % ~/mypath.jpg 41 | Then the home directory should contain mypath.jpg 42 | 43 | Scenario: Do not expand tilde when escaped 44 | Given I open a directory with 1 paths 45 | When I run !cp -r % \~ 46 | Then the directory ~ should exist 47 | 48 | Scenario: Expand %f to the list of paths in the library 49 | Given I open a directory with 2 paths 50 | When I run !rmdir * 51 | Then the directory child_01 should not exist 52 | And the directory child_02 should not exist 53 | -------------------------------------------------------------------------------- /tests/end2end/features/command/external.feature: -------------------------------------------------------------------------------- 1 | Feature: Running external commands. 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Run an external command. 7 | When I run !touch file 8 | Then the file file should exist 9 | And no message should be displayed 10 | 11 | Scenario: Fail an external command. 12 | When I run !not-a-shell-command 13 | Then the message 14 | 'Error running 'not-a-shell-command': command not found or not executable' 15 | should be displayed 16 | 17 | @flaky 18 | Scenario: Pipe directory to vimiv. 19 | When I create the directory 'new_directory' 20 | And I run !ls | 21 | Then the working directory should be new_directory 22 | 23 | Scenario: Fail piping to vimiv. 24 | When I run !ls | 25 | Then the message 26 | 'ls: No paths from pipe' 27 | should be displayed 28 | 29 | Scenario: Use spawn with sub-shell 30 | When I run spawn echo anything > test.txt 31 | Then the file test.txt should exist 32 | -------------------------------------------------------------------------------- /tests/end2end/features/command/fail_run_command.feature: -------------------------------------------------------------------------------- 1 | Feature: Do not crash and display error message on failed commands. 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Crash when running unknown command. 7 | When I run not-a-command 8 | Then no crash should happen 9 | And the message 10 | 'not-a-command: unknown command for mode library' 11 | should be displayed 12 | 13 | Scenario: Crash when running command with unknown arguments. 14 | When I run quit --now 15 | Then no crash should happen 16 | And the message 17 | 'quit: Unrecognized arguments: --now' 18 | should be displayed 19 | 20 | Scenario: Crash when running command with missing positional argument. 21 | When I run scroll 22 | Then no crash should happen 23 | And the message 24 | 'scroll: The following arguments are required: direction' 25 | should be displayed 26 | 27 | Scenario: Crash on plain number command 28 | When I run 3 29 | Then no crash should happen 30 | 31 | Scenario: Crash on unclosed quote 32 | When I run ' 33 | Then no crash should happen 34 | 35 | Scenario: Crash on empty command 36 | When I run 37 | Then no crash should happen 38 | -------------------------------------------------------------------------------- /tests/end2end/features/command/misccommands.feature: -------------------------------------------------------------------------------- 1 | Feature: Run miscellaneous commands. 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Log an info message 7 | When I run log info my message 8 | Then the message 9 | 'my message' 10 | should be displayed 11 | 12 | Scenario: Log a warning message 13 | When I run log warning oh oh 14 | Then the message 15 | 'oh oh' 16 | should be displayed 17 | 18 | Scenario: Log an error message 19 | When I run log error this is bad 20 | Then the message 21 | 'this is bad' 22 | should be displayed 23 | 24 | Scenario: Fail logging a message 25 | When I run log basement spiders 26 | Then the message 27 | 'log: Unknown log level 'basement'' 28 | should be displayed 29 | 30 | Scenario: Sleep for some time 31 | Given I start a timer 32 | When I run sleep 0.01 33 | Then at least 0.01 seconds should have elapsed 34 | -------------------------------------------------------------------------------- /tests/end2end/features/command/repeat.feature: -------------------------------------------------------------------------------- 1 | Feature: Repeat the last command. 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Repeat command 7 | When I run count 8 | And I run repeat-command 9 | Then the count should be 2 10 | 11 | Scenario: Repeat command with given count. 12 | When I run count 13 | And I run 2repeat-command 14 | Then the count should be 3 15 | 16 | Scenario: Repeat command with stored count. 17 | When I run 2count 18 | And I run repeat-command 19 | Then the count should be 4 20 | 21 | Scenario: Display error message when running repeat-command before running anything. 22 | When I run repeat-command 23 | Then no crash should happen 24 | And the message 25 | 'repeat-command: No command to repeat' 26 | should be displayed 27 | 28 | Scenario: Display error message when running repeat-command in other mode. 29 | When I run count 30 | And I enter image mode 31 | When I run repeat-command 32 | Then no crash should happen 33 | And the message 34 | 'repeat-command: No command to repeat' 35 | should be displayed 36 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_aliases_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | from vimiv.commands import aliases 7 | 8 | 9 | bdd.scenarios("aliases.feature") 10 | 11 | 12 | @bdd.then(bdd.parsers.parse("the alias {name} should not exist")) 13 | def check_alias_non_existent(name): 14 | assert name not in aliases.get(api.modes.GLOBAL) 15 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_chaining_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("chaining.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_expand_wildcards_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("expand_wildcards.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_external_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("external.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_fail_run_command_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | from vimiv.commands import runners 7 | 8 | 9 | bdd.scenarios("fail_run_command.feature") 10 | 11 | 12 | @bdd.when("I run") 13 | def run_empty_command(): 14 | runners.run("", mode=api.modes.current()) 15 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_misccommands_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import time 4 | 5 | import pytest_bdd as bdd 6 | 7 | 8 | bdd.scenarios("misccommands.feature") 9 | 10 | 11 | @bdd.given("I start a timer", target_fixture="starttime") 12 | def starttime(): 13 | return time.time() 14 | 15 | 16 | @bdd.then(bdd.parsers.parse("at least {duration:f} seconds should have elapsed")) 17 | def check_time_elapsed(starttime, duration): 18 | elapsed = time.time() - starttime 19 | assert elapsed >= duration 20 | starttime = None 21 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_repeat_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("repeat.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/command/test_search_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv import api 7 | from vimiv.commands import search 8 | 9 | 10 | bdd.scenarios("search.feature") 11 | 12 | 13 | class SearchResults: 14 | """Helper class to store search results.""" 15 | 16 | def __init__(self): 17 | self.results = [] 18 | search.search.new_search.connect(self._on_search) 19 | search.search.cleared.connect(self._on_search_cleared) 20 | 21 | def _on_search(self, _index, results, _mode, _incsearch): 22 | self.results = results 23 | 24 | def _on_search_cleared(self): 25 | self.results.clear() 26 | 27 | def __len__(self): 28 | return len(self.results) 29 | 30 | 31 | @pytest.fixture(autouse=True) 32 | def search_results(): 33 | """Fixture to retrieve a clean helper class to store search results.""" 34 | return SearchResults() 35 | 36 | 37 | @bdd.when(bdd.parsers.parse("I search for {text}")) 38 | def run_search(text): 39 | search.search(text, api.modes.current()) 40 | 41 | 42 | @bdd.when(bdd.parsers.parse("I search in reverse for {text}")) 43 | def run_search_reverse(text): 44 | search.search(text, api.modes.current(), reverse=True) 45 | 46 | 47 | @bdd.then(bdd.parsers.parse("There should be {n:d} search matches")) 48 | def check_search_matches(search_results, n): 49 | assert len(search_results) == n 50 | -------------------------------------------------------------------------------- /tests/end2end/features/config/configcommands.feature: -------------------------------------------------------------------------------- 1 | Feature: Miscellaneous commands related to the config 2 | 3 | Scenario: Toggle boolean setting 4 | Given I start vimiv 5 | When I run set completion.fuzzy! 6 | Then the boolean setting 'completion.fuzzy' should be 'true' 7 | 8 | Scenario: Toggle boolean setting twice 9 | Given I start vimiv 10 | When I run set completion.fuzzy! 11 | And I run set completion.fuzzy! 12 | Then the boolean setting 'completion.fuzzy' should be 'false' 13 | 14 | Scenario: Reset setting to default value 15 | Given I start vimiv 16 | When I run set completion.fuzzy false 17 | And I run set completion.fuzzy 18 | Then the boolean setting 'completion.fuzzy' should be 'false' 19 | 20 | Scenario: Do not crash on unknown setting 21 | Given I start vimiv 22 | When I run set anything.unknown false 23 | Then no crash should happen 24 | And the message 25 | 'set: unknown setting 'anything.unknown'' 26 | should be displayed 27 | 28 | Scenario: Do not crash on unsupported setting operation 29 | Given I start vimiv 30 | When I run set completion.fuzzy +10 31 | Then no crash should happen 32 | And the message 33 | 'set: 'completion.fuzzy' does not support adding' 34 | should be displayed 35 | 36 | Scenario: Do not crash on wrong setting value 37 | Given I start vimiv 38 | When I run set library.width forty 39 | Then no crash should happen 40 | And the message 41 | 'set: Cannot convert 'forty' to Float' 42 | should be displayed 43 | -------------------------------------------------------------------------------- /tests/end2end/features/config/test_configcommands_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("configcommands.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv.parser import geometry 7 | from vimiv.imutils import _ImageFileHandler 8 | 9 | 10 | @pytest.fixture() 11 | def file_handler(): 12 | """Fixture to retrieve the current instance of the edit handler.""" 13 | return _ImageFileHandler.instance 14 | 15 | 16 | @pytest.fixture() 17 | def edit_handler(file_handler): 18 | """Fixture to retrieve the current instance of the edit handler.""" 19 | return file_handler._edit_handler 20 | 21 | 22 | @bdd.then(bdd.parsers.parse("the image size should be {size}")) 23 | def ensure_size(size, image): 24 | expected = geometry(size) 25 | image_rect = image.sceneRect() 26 | assert expected.width() == pytest.approx(image_rect.width(), abs=1) 27 | assert expected.height() == pytest.approx(image_rect.height(), abs=1) 28 | 29 | 30 | @bdd.then(bdd.parsers.parse("the image size should not be {size}")) 31 | def ensure_size_not(size, image): 32 | expected = geometry(size) 33 | image_rect = image.sceneRect() 34 | width_neq = expected.width() != image_rect.width() 35 | height_neq = expected.height() != image_rect.height() 36 | assert width_neq or height_neq 37 | 38 | 39 | @bdd.then("the image should not be edited") 40 | def ensure_not_edited(edit_handler): 41 | assert not edit_handler.changed 42 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/crop.feature: -------------------------------------------------------------------------------- 1 | Feature: Crop an image. 2 | 3 | Background: 4 | Given I open any image of size 300x200 5 | 6 | Scenario: Enter crop widget 7 | When I run crop 8 | Then there should be 1 crop widget 9 | And the center status should include crop: 10 | 11 | Scenario: Enter crop widget with fixed aspectratio 12 | When I run crop --aspectratio=1:1 13 | Then there should be 1 crop widget 14 | And the center status should include crop: 15 | 16 | Scenario: Leave crop widget without changes 17 | When I run crop 18 | And I press '' in the crop widget 19 | Then there should be 0 crop widgets 20 | And the image size should be 300x200 21 | 22 | Scenario: Leave crop widget accepting changes 23 | When I run crop 24 | And I press '' in the crop widget 25 | Then there should be 0 crop widgets 26 | And the image size should be 150x100 27 | 28 | Scenario Outline: Drag crop widget with the mouse 29 | When I run crop 30 | And I drag the crop widget by + 31 | Then the crop rectangle should be 32 | 33 | Examples: 34 | | dx | dy | geometry | 35 | | 0 | 0 | 150x100+75+50 | 36 | # small dx dy 37 | | 30 | -20 | 150x100+105+30 | 38 | # dx only as far as the image allows 39 | | 125 | 0 | 150x100+150+50 | 40 | # dy only as far as the image allows 41 | | 10 | -100 | 150x100+85+0 | 42 | # Ignored as dx/dy are outside of the image 43 | | 1000 | 1000 | 150x100+75+50 | 44 | 45 | Scenario: Crop does not automatically consider a gif edited 46 | Given I open an image and an animated gif 47 | When I run next 48 | Then the image should not be edited 49 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/manipulate_segfault.feature: -------------------------------------------------------------------------------- 1 | Feature: Segfault when manipulating an image. 2 | 3 | Scenario: Segfault when applying manipulations to images larger than screen-size 4 | Given I open any image of size 1200x900 5 | When I enter manipulate mode 6 | And I apply any manipulation 7 | And I run accept 8 | Then no crash should happen 9 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/straighten.feature: -------------------------------------------------------------------------------- 1 | Feature: Straighten an image. 2 | 3 | Background: 4 | Given I open any image of size 300x200 5 | 6 | Scenario: Enter straighten widget 7 | When I run straighten 8 | Then there should be 1 straighten widget 9 | And the center status should include angle: +0.0° 10 | 11 | Scenario: Leave straighten widget discarding changes 12 | When I run straighten 13 | And I straighten by 1 degree 14 | And I press '' in the straighten widget 15 | Then there should be 0 straighten widgets 16 | And the image size should be 300x200 17 | 18 | Scenario: Straighten repeatedly 19 | When I run straighten 20 | And I straighten by 1 degree 21 | And I straighten by 1 degree 22 | Then the straighten angle should be 2.0 23 | 24 | Scenario: Leave straighten widget accepting changes 25 | When I run straighten 26 | And I straighten by 1 degree 27 | And I press '' in the straighten widget 28 | Then there should be 0 straighten widgets 29 | And the image size should not be 300x200 30 | 31 | Scenario: Straighten image using keybindings 32 | When I run straighten 33 | And I press 'l' in the straighten widget 34 | Then the straighten angle should be 0.2 35 | 36 | Scenario: Straighten already transformed image 37 | When I run rotate 38 | And I run straighten 39 | And I straighten by 1 degree 40 | And I straighten by -1 degree 41 | Then the image size should be 200x300 42 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/test_manipulate_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | import vimiv.imutils.immanipulate 7 | 8 | bdd.scenarios("manipulate.feature", "manipulate_segfault.feature") 9 | 10 | 11 | @pytest.fixture() 12 | def manipulator(): 13 | return vimiv.imutils.immanipulate.Manipulator.instance 14 | 15 | 16 | @pytest.fixture() 17 | def manipulation(manipulator): 18 | return manipulator._current_manipulation 19 | 20 | 21 | @bdd.when("I apply any manipulation") 22 | def apply_any_manipulation(manipulator, qtbot): 23 | with qtbot.waitSignal(manipulator.updated) as _: 24 | manipulator.goto(10) 25 | 26 | 27 | @bdd.then(bdd.parsers.parse("The current value should be {value:d}")) 28 | def check_current_manipulation_value(manipulation, value): 29 | assert manipulation.value == value # Actual value 30 | 31 | 32 | @bdd.then(bdd.parsers.parse("The current manipulation should be {name}")) 33 | def check_current_manipulation_name(manipulation, name): 34 | assert manipulation.name == name 35 | 36 | 37 | @bdd.then(bdd.parsers.parse("There should be {n_changes:d} stored changes")) 38 | def check_stored_changes(manipulator, n_changes): 39 | assert len(manipulator._changes) == n_changes 40 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/test_straighten_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | import vimiv.gui.straightenwidget 7 | 8 | 9 | bdd.scenarios("straighten.feature") 10 | 11 | 12 | def find_straighten_widgets(image): 13 | return image.findChildren(vimiv.gui.straightenwidget.StraightenWidget) 14 | 15 | 16 | @pytest.fixture() 17 | def straighten(image): 18 | """Fixture to retrieve the current instance of the straighten widget.""" 19 | widgets = find_straighten_widgets(image) 20 | assert len(widgets) == 1, "Wrong number of straighten wigets found" 21 | return widgets[0] 22 | 23 | 24 | @bdd.when(bdd.parsers.parse("I straighten by {angle:g} degrees")) 25 | @bdd.when(bdd.parsers.parse("I straighten by {angle:g} degree")) 26 | def straighten_by(qtbot, straighten, angle): 27 | def check(): 28 | assert (straighten.transform.angle) % 90 == pytest.approx(expected_angle % 90) 29 | 30 | expected_angle = straighten.angle + angle 31 | 32 | straighten.rotate(angle=angle) 33 | qtbot.waitUntil(check) 34 | 35 | 36 | @bdd.when(bdd.parsers.parse("I press '{keys}' in the straighten widget")) 37 | def press_key_straighten(keypress, straighten, keys): 38 | keypress(straighten, keys) 39 | 40 | 41 | @bdd.then(bdd.parsers.parse("there should be {number:d} straighten widgets")) 42 | @bdd.then(bdd.parsers.parse("there should be {number:d} straighten widget")) 43 | def check_number_of_straighten_widgets(qtbot, image, number): 44 | def check(): 45 | assert len(find_straighten_widgets(image)) == number 46 | 47 | qtbot.waitUntil(check) 48 | 49 | 50 | @bdd.then(bdd.parsers.parse("the straighten angle should be {angle:.f}")) 51 | def check_straighten_angle(qtbot, straighten, angle): 52 | def check(): 53 | assert straighten.angle == pytest.approx(angle) 54 | 55 | qtbot.waitUntil(check) 56 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/test_transform_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("transform.feature") 7 | 8 | 9 | @bdd.then(bdd.parsers.parse("the orientation should be {orientation}")) 10 | def ensure_orientation(image, orientation): 11 | orientation = orientation.lower() 12 | scenerect = image.scene().sceneRect() 13 | if orientation == "landscape": 14 | assert scenerect.width() > scenerect.height() 15 | elif orientation == "portrait": 16 | assert scenerect.height() > scenerect.width() 17 | else: 18 | raise ValueError(f"Unkown orientation {orientation}") 19 | -------------------------------------------------------------------------------- /tests/end2end/features/edit/transform.feature: -------------------------------------------------------------------------------- 1 | Feature: Transform an image. 2 | 3 | Scenario: Crash when running transform without image 4 | Given I start vimiv 5 | When I enter image mode 6 | And I run rotate 7 | Then no crash should happen 8 | 9 | Scenario: Rotate landscape image 10 | Given I open any image of size 300x200 11 | When I run rotate 12 | Then the orientation should be portrait 13 | 14 | Scenario: Rotate landscape image twice 15 | Given I open any image of size 300x200 16 | When I run 2rotate 17 | Then the orientation should be landscape 18 | 19 | Scenario: Rotate portrait image three times counter-clockwise 20 | Given I open any image of size 200x300 21 | When I run 3rotate --counter-clockwise 22 | Then the orientation should be landscape 23 | 24 | Scenario: Rescale image 25 | Given I open any image of size 300x200 26 | When I run rescale 2 27 | Then the image size should be 600x400 28 | 29 | Scenario: Rescale image changing the aspect ratio 30 | Given I open any image of size 300x200 31 | When I run rescale 2 1 32 | Then the image size should be 600x200 33 | 34 | Scenario: Resize image 35 | Given I open any image of size 300x200 36 | When I run resize 150 37 | Then the image size should be 150x100 38 | 39 | Scenario: Resize image changing the aspect ratio 40 | Given I open any image of size 300x200 41 | When I run resize 150 200 42 | Then the image size should be 150x200 43 | 44 | Scenario: Undo transformations 45 | Given I open any image of size 300x200 46 | When I run resize 150 47 | And I run undo-transformations 48 | Then the image size should be 300x200 49 | 50 | Scenario: Do not allow transforming when read_only is active 51 | Given I open any image of size 300x200 52 | When I run set read_only true 53 | And I run rotate 54 | Then the message 55 | 'rotate: Disabled due to read-only being active' 56 | should be displayed 57 | And the orientation should be landscape 58 | -------------------------------------------------------------------------------- /tests/end2end/features/image/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import os 4 | 5 | import pytest 6 | import pytest_bdd as bdd 7 | 8 | from vimiv import imutils, utils 9 | 10 | try: 11 | import piexif 12 | except ImportError: 13 | piexif = None 14 | 15 | 16 | @pytest.fixture() 17 | def exif_content(): 18 | assert piexif is not None, "piexif required create exif information." 19 | return { 20 | "0th": { 21 | piexif.ImageIFD.Make: b"vimiv-testsuite", 22 | piexif.ImageIFD.Model: b"image-viewer", 23 | piexif.ImageIFD.Copyright: b"vimiv-AUTHORS-2020", 24 | }, 25 | "Exif": {piexif.ExifIFD.ExposureTime: (1, 200)}, 26 | "GPS": {piexif.GPSIFD.GPSAltitude: (1234, 1)}, 27 | } 28 | 29 | 30 | @pytest.fixture() 31 | def handler(): 32 | return imutils._ImageFileHandler.instance 33 | 34 | 35 | @bdd.when("I add exif information") 36 | def add_exif_information_bdd(add_exif_information, handler, exif_content): 37 | assert piexif is not None, "piexif required to add exif information" 38 | # Wait for thumbnail creation so we don't interfere with the current reading by 39 | # adding more bytes 40 | utils.Pool.wait(5000) 41 | add_exif_information(handler._path, exif_content) 42 | 43 | 44 | @bdd.then(bdd.parsers.parse("the image number {number:d} should be {basename}")) 45 | def check_image_name_at_position(number, basename): 46 | image = imutils.pathlist()[number - 1] 47 | assert os.path.basename(image) == basename 48 | -------------------------------------------------------------------------------- /tests/end2end/features/image/gif.feature: -------------------------------------------------------------------------------- 1 | @imageformats 2 | Feature: Open and play animated gifs 3 | 4 | Background: 5 | Given I open an animated gif 6 | 7 | Scenario: Autoplay animated gif 8 | Then the animation should be playing 9 | 10 | Scenario: Pause animated gif 11 | When I run play-or-pause 12 | Then the animation should be paused 13 | 14 | Scenario: Do not rotate animated gif 15 | When I run rotate 16 | Then the message 17 | 'rotate: File format does not support transform' 18 | should be displayed 19 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imagefit.feature: -------------------------------------------------------------------------------- 1 | Feature: Fitting the image displayed. 2 | 3 | Scenario: Fit landscape image to width. 4 | Given I open any image of size 2000x500 5 | When I run scale --level=fit 6 | Then the pixmap width should fit 7 | 8 | Scenario: Fit portrait image to height. 9 | Given I open any image of size 500x2000 10 | When I run scale --level=fit 11 | Then the pixmap height should fit 12 | 13 | Scenario: Fit landscape image to height. 14 | Given I open any image of size 2000x500 15 | When I run scale --level=fit-height 16 | Then the pixmap height should fit 17 | And the pixmap width should not fit 18 | 19 | Scenario: Fit portrait image to width. 20 | Given I open any image of size 500x2000 21 | When I run scale --level=fit-width 22 | Then the pixmap width should fit 23 | And the pixmap height should not fit 24 | 25 | Scenario: Fit image to float scale. 26 | Given I open any image of size 200x200 27 | When I run scale --level=2 28 | Then the pixmap width should be 400 29 | And the pixmap height should be 400 30 | 31 | Scenario: Do not overzoom small image. 32 | Given I open any image of size 200x200 33 | When I run scale --level=overzoom 34 | Then the pixmap width should be 200 35 | And the pixmap height should be 200 36 | 37 | Scenario: Scale down large image on overzoom. 38 | Given I open any image of size 2000x500 39 | When I run scale --level=overzoom 40 | Then the pixmap width should fit 41 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imagenavigate.feature: -------------------------------------------------------------------------------- 1 | Feature: Navigating through the displayed images 2 | 3 | Scenario: Move to next image 4 | Given I open 5 images 5 | When I run next 6 | Then the image should have the index 2 7 | 8 | Scenario: Move to next image and back 9 | Given I open 5 images 10 | When I run next 11 | And I run prev 12 | Then the image should have the index 1 13 | 14 | Scenario: Move to last image by wrapping :prev 15 | Given I open 5 images 16 | When I run prev 17 | Then the image should have the index 5 18 | 19 | Scenario: Wrap back and forth 20 | Given I open 5 images 21 | When I run prev 22 | And I run next 23 | Then the image should have the index 1 24 | 25 | Scenario: Move to last image with goto 26 | Given I open 5 images 27 | When I run goto -1 28 | Then the image should have the index 5 29 | 30 | Scenario: Move to last image and back to first with goto 31 | Given I open 5 images 32 | When I run goto -1 33 | And I run goto 1 34 | Then the image should have the index 1 35 | 36 | Scenario: Move to specific image using goto with count 37 | Given I open 5 images 38 | When I run 3goto 39 | Then the image should have the index 3 40 | 41 | Scenario: Crash on goto without images 42 | Given I start vimiv 43 | When I enter image mode 44 | When I run goto 3 45 | Then no crash should happen 46 | And the message 47 | 'goto: No image in list' 48 | should be displayed 49 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imageopen.feature: -------------------------------------------------------------------------------- 1 | Feature: Open different images and image formats 2 | 3 | Scenario: Error on invalid image formats 4 | Given I start vimiv 5 | When I open broken images 6 | Then no crash should happen 7 | 8 | # This does the magic to open all images in the directory 9 | # Therefore only the index, not the total number of images changes 10 | Scenario: Open single image using the open command 11 | Given I open 5 images 12 | When I run open image_03.jpg 13 | Then the filelist should contain 5 images 14 | And the image should have the index 3 15 | 16 | Scenario: Open multiple images using the open command 17 | Given I open 5 images 18 | When I run open image_01.jpg image_02.jpg 19 | Then the filelist should contain 2 images 20 | 21 | Scenario: Open image using unix-style asterisk pattern expansion 22 | Given I open 11 images 23 | When I run open image_1*.jpg 24 | Then the filelist should contain 2 images 25 | 26 | Scenario: Open image using unix-style question mark pattern expansion 27 | Given I open 11 images 28 | When I run open image_1?.jpg 29 | Then the filelist should contain 2 images 30 | 31 | Scenario: Open image using unix-style group pattern expansion 32 | Given I open 12 images 33 | When I run open image_1[01].jpg 34 | Then the filelist should contain 2 images 35 | 36 | Scenario: Open path that does not exist 37 | Given I open any image 38 | When I run open not/a/path 39 | Then no crash should happen 40 | And the message 41 | 'open: No paths matching 'not/a/path'' 42 | should be displayed 43 | 44 | Scenario: Open invalid path 45 | Given I open any image 46 | When I create the file 'not_an_image' 47 | And I run open not_an_image 48 | Then no crash should happen 49 | And the message 50 | 'open: No valid paths' 51 | should be displayed 52 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imageorder.feature: -------------------------------------------------------------------------------- 1 | Feature: Ordering the image filelist. 2 | 3 | Scenario: Re-order current filelist 4 | Given I open 12 images without leading zeros in their name 5 | When I run set sort.image_order natural 6 | Then the image should have the index 1 7 | And the image number 1 should be image_1.jpg 8 | And the image number 2 should be image_2.jpg 9 | And the image number 11 should be image_11.jpg 10 | 11 | Scenario: Set none sorting after another sort 12 | Given I open 12 images without leading zeros in their name 13 | When I run set sort.image_order natural 14 | Then the image should have the index 1 15 | And the image number 1 should be image_1.jpg 16 | And the image number 2 should be image_2.jpg 17 | And the image number 11 should be image_11.jpg 18 | When I run set sort.image_order none 19 | Then the image should have the index 1 20 | And the image number 1 should be image_1.jpg 21 | And the image number 2 should be image_2.jpg 22 | And the image number 11 should be image_11.jpg 23 | 24 | Scenario: Reverse none sorting 25 | Given I open 5 images 26 | When I run set sort.image_order none 27 | Then the image should have the index 1 28 | When I run set sort.reverse 29 | Then the image should have the index 1 30 | 31 | Scenario: Reverse current filelist 32 | Given I open 5 images 33 | When I run set sort.reverse! 34 | # We revert the sorting, but keep the selection 35 | Then the image should have the index 5 36 | 37 | Scenario: Shuffle current filelist 38 | Given I open 15 images 39 | When I run set sort.shuffle! 40 | # We revert the sorting, but keep the selection 41 | Then the filelist should not be ordered 42 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imagescroll.feature: -------------------------------------------------------------------------------- 1 | Feature: Scroll the current image 2 | 3 | Background: 4 | Given I open any image 5 | 6 | Scenario: Scroll to left edge 7 | When I run 10zoom in 8 | And I run scroll-edge left 9 | Then the image left-edge should be 0 10 | 11 | Scenario: Scroll to right edge 12 | When I run 10zoom in 13 | And I run scroll-edge right 14 | Then the image right-edge should be 300 15 | 16 | Scenario: Keep scroll position when size changes 17 | When I run 10zoom in 18 | And I run scroll-edge left 19 | And I resize the image 20 | Then the image left-edge should be 0 21 | 22 | Scenario Outline: Scroll image 23 | When I run 10zoom in 24 | And I run scroll 25 | Then the image should not be 26 | 27 | Examples: 28 | | direction | position | value | 29 | | right | left-edge | 0 | 30 | | down | top-edge | 0 | 31 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imagetitle.feature: -------------------------------------------------------------------------------- 1 | Feature: Show current image name in window title 2 | 3 | Scenario: Show name of opened image in title 4 | Given I open any image 5 | Then the image name should be in the window title 6 | -------------------------------------------------------------------------------- /tests/end2end/features/image/imagezoom.feature: -------------------------------------------------------------------------------- 1 | Feature: Zooming the image displayed. 2 | 3 | Scenario: Zooming in. 4 | Given I open any image of size 200x200 5 | When I run zoom in 6 | Then the zoom level should be 1.25 7 | 8 | Scenario: Zooming out. 9 | Given I open any image of size 200x200 10 | When I run zoom out 11 | Then the zoom level should be 0.8 12 | 13 | Scenario: Zooming in and out. 14 | Given I open any image of size 200x200 15 | When I run zoom in 16 | And I run zoom out 17 | Then the zoom level should be 1.0 18 | 19 | Scenario: Keep zoom level when reloading image. 20 | Given I open any image of size 200x200 21 | When I run zoom in 22 | And I run reload 23 | Then the zoom level should not be 1.0 24 | 25 | Scenario: Keep zoom level with --keep-zoom flag. 26 | Given I open any image of size 200x200 27 | When I run zoom in 28 | And I run next --keep-zoom 29 | Then the zoom level should be 1.25 30 | -------------------------------------------------------------------------------- /tests/end2end/features/image/multidirectory.feature: -------------------------------------------------------------------------------- 1 | Feature: Support for images from multiple directories in image mode 2 | 3 | # Reasonably complicated directory structure: 4 | # . 5 | # ├── dir1 6 | # │   ├── image_dir1_1.png 7 | # │   ├── image_dir1_2.png 8 | # │   └── image_dir1_3.png 9 | # ├── dir2 10 | # │   ├── also_image_dir2_1.png 11 | # │   └── also_image_dir2_2.png 12 | # └── dir3 13 | # ├── more_image_dir3_1.png 14 | # ├── more_image_dir3_2.png 15 | # ├── more_image_dir3_3.png 16 | # └── more_image_dir3_4.png 17 | 18 | Background: 19 | Given I open images from multiple directories 20 | 21 | Scenario: Open all images in single filelist 22 | Then the filelist should contain 9 images 23 | And the image should have the index 1 24 | # We sort all images according to the basename 25 | And the image number 1 should be also_image_dir2_1.png 26 | 27 | Scenario: Reverse sorting of images from multiple directories 28 | When I run set sort.reverse! 29 | Then the image should have the index 9 30 | And the image number 1 should be more_image_dir3_4.png 31 | -------------------------------------------------------------------------------- /tests/end2end/features/image/slideshow.feature: -------------------------------------------------------------------------------- 1 | Feature: Play a slideshow. 2 | 3 | Scenario: Start playing slideshow 4 | Given I open any image 5 | When I run slideshow 6 | Then the center status should include slideshow 7 | And the slideshow should be playing 8 | 9 | Scenario: Start and stop slideshow 10 | Given I open any image 11 | When I run slideshow 12 | And I run slideshow 13 | Then the slideshow should not be playing 14 | 15 | Scenario: Set slideshow delay via setting 16 | Given I open any image 17 | When I run set slideshow.delay 4 18 | Then the slideshow delay should be 4.0 19 | 20 | Scenario: Set slideshow delay via count 21 | Given I open any image 22 | When I run 5slideshow 23 | Then the slideshow delay should be 5.0 24 | 25 | Scenario: Slideshow updates the displayed image 26 | Given I open 5 images 27 | And I forcefully set the slideshow delay to 10ms 28 | When I run slideshow 29 | And I let the slideshow run 2 times 30 | Then the image should have the index 3 31 | And the left status should include 03 32 | 33 | Scenario: Start slideshow upon startup 34 | Given I open 5 images with --command slideshow 35 | Then the slideshow should be playing 36 | 37 | Scenario: Leave slideshow when image is unfocused 38 | Given I open any image 39 | When I run slideshow 40 | And I unfocus the image 41 | Then the slideshow should not be playing 42 | -------------------------------------------------------------------------------- /tests/end2end/features/image/svg.feature: -------------------------------------------------------------------------------- 1 | @imageformats 2 | Feature: Open vector graphics 3 | 4 | Background: 5 | Given I open a vector graphic 6 | 7 | Scenario: Zoom vector graphic 8 | When I run zoom in 9 | Then no crash should happen 10 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_gif_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | 5 | import pytest_bdd as bdd 6 | 7 | 8 | bdd.scenarios("gif.feature") 9 | 10 | 11 | @pytest.fixture() 12 | def movie(image): 13 | widget = image.items()[0].widget() 14 | return widget.movie() 15 | 16 | 17 | @bdd.then("the animation should be playing") 18 | def check_animation_playing(movie): 19 | assert movie.state() == movie.MovieState.Running 20 | 21 | 22 | @bdd.then("the animation should be paused") 23 | def check_animation_paused(movie): 24 | assert movie.state() == movie.MovieState.Paused 25 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imagedelete_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import os 4 | 5 | import pytest_bdd as bdd 6 | 7 | from vimiv.imutils import filelist 8 | 9 | 10 | bdd.scenarios("imagedelete.feature") 11 | 12 | 13 | @bdd.when("I remove move permissions") 14 | def remove_move_permissions(mocker): 15 | mocker.patch("shutil.move", side_effect=PermissionError) 16 | 17 | 18 | @bdd.then(bdd.parsers.parse("{basename} should not be in the filelist")) 19 | def check_image_not_in_filelist(basename): 20 | abspath = os.path.abspath(basename) 21 | assert abspath not in filelist._paths 22 | 23 | 24 | @bdd.then("the image widget should be empty") 25 | def check_image_widget_empty(image): 26 | assert not image.items() 27 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imagefit_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("imagefit.feature") 7 | 8 | 9 | def almost_equal(size, expected): 10 | """Check if size is almost equal to the expected value. 11 | 12 | When scaling images the float value scale times the integer original size 13 | can lead to the size being smaller than the expected value by one. 14 | """ 15 | assert size + 1 >= expected 16 | assert size <= expected 17 | 18 | 19 | @bdd.then(bdd.parsers.parse("the pixmap width should be {width}")) 20 | def check_pixmap_width(image, width): 21 | almost_equal(image.scene().sceneRect().width() * image.zoom_level, int(width)) 22 | 23 | 24 | @bdd.then(bdd.parsers.parse("the pixmap height should be {height}")) 25 | def check_pixmap_height(image, height): 26 | almost_equal(image.scene().sceneRect().height() * image.zoom_level, int(height)) 27 | 28 | 29 | @bdd.then(bdd.parsers.parse("the pixmap width should fit")) 30 | def check_pixmap_width_fit(image): 31 | almost_equal( 32 | image.viewport().width(), image.scene().sceneRect().width() * image.zoom_level 33 | ) 34 | 35 | 36 | @bdd.then(bdd.parsers.parse("the pixmap height should fit")) 37 | def check_pixmap_height_fit(image): 38 | almost_equal( 39 | image.viewport().height(), image.scene().sceneRect().height() * image.zoom_level 40 | ) 41 | 42 | 43 | @bdd.then(bdd.parsers.parse("the pixmap width should not fit")) 44 | def check_pixmap_width_no_fit(image): 45 | assert ( 46 | image.viewport().width() != image.scene().sceneRect().width() * image.zoom_level 47 | ) 48 | 49 | 50 | @bdd.then(bdd.parsers.parse("the pixmap height should not fit")) 51 | def check_pixmap_height_no_fit(image): 52 | assert ( 53 | image.viewport().height() 54 | != image.scene().sceneRect().height() * image.zoom_level 55 | ) 56 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imagenavigate_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("imagenavigate.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imageopen_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | from vimiv.utils import imageheader 7 | 8 | 9 | bdd.scenarios("imageopen.feature") 10 | 11 | 12 | @bdd.when("I open broken images") 13 | def open_broken_images(tmp_path): 14 | _open_file(tmp_path, b"\x89PNG\x0D\x0A\x1A\x0A") # PNG 15 | _open_file(tmp_path, b"\xFF\xD8\xFF\xDB") # JPG 16 | _open_file(tmp_path, b"GIF89a") # GIF 17 | _open_file(tmp_path, b"II\x2A\x00") # TIFF 18 | _open_file(tmp_path, b"BM") # BMP 19 | 20 | 21 | def _open_file(directory, data): 22 | """Open a file containing the bytes from data.""" 23 | path = directory / "broken" 24 | path.write_bytes(data) 25 | filename = str(path) 26 | assert imageheader.detect(filename) is not None, "Invalid magic bytes in test setup" 27 | api.open_paths([filename]) 28 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imageorder_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api, imutils 6 | 7 | 8 | bdd.scenarios("imageorder.feature") 9 | 10 | 11 | @bdd.then("the filelist should not be ordered") 12 | def check_filelist_not_ordered(): 13 | paths = imutils.pathlist() 14 | ordered_paths = api.settings.sort.image_order.sort(paths) 15 | assert paths != ordered_paths 16 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imagescroll_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv.qt.gui import QResizeEvent 6 | 7 | 8 | bdd.scenarios("imagescroll.feature") 9 | 10 | 11 | @bdd.when("I resize the image") 12 | def resize_image(image): 13 | image.resizeEvent(QResizeEvent(image.size(), image.size())) 14 | 15 | 16 | @bdd.then(bdd.parsers.parse("the image {position} should be {value:d}")) 17 | def check_image_coordinate(image, position, value): 18 | rect = image.visible_rect 19 | assert position_to_value(position, rect) == value 20 | 21 | 22 | @bdd.then(bdd.parsers.parse("the image {position} should not be {value:d}")) 23 | def check_image_not_coordinate(image, position, value): 24 | rect = image.visible_rect 25 | assert position_to_value(position, rect) != value 26 | 27 | 28 | def position_to_value(position, rect): 29 | position = position.lower() 30 | if position == "left-edge": 31 | return rect.x() 32 | if position == "right-edge": 33 | return rect.x() + rect.width() 34 | if position == "top-edge": 35 | return rect.y() 36 | raise ValueError(f"Unknown position '{position}'") 37 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imagetitle_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv.imutils import filelist 6 | 7 | 8 | bdd.scenarios("imagetitle.feature") 9 | 10 | 11 | @bdd.then("the image name should be in the window title") 12 | def image_name_in_title(mainwindow): 13 | assert filelist.basename() in mainwindow.windowTitle() 14 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_imagezoom_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | 7 | bdd.scenarios("imagezoom.feature") 8 | 9 | 10 | @bdd.then(bdd.parsers.parse("the zoom level should be {level:f}")) 11 | def check_zoom_level(image, level): 12 | assert level == pytest.approx(image.zoom_level, 0.01) 13 | 14 | 15 | @bdd.then(bdd.parsers.parse("the zoom level should not be {level:f}")) 16 | def check_zoom_level_not(image, level): 17 | assert level != pytest.approx(image.zoom_level, 0.01) 18 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_metadata_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv.imutils import metadata 7 | 8 | 9 | bdd.scenarios("metadata.feature") 10 | 11 | 12 | @pytest.fixture 13 | def metadatawidget(): 14 | if metadata.has_metadata_support(): 15 | from vimiv.gui.metadatawidget import MetadataWidget 16 | 17 | return MetadataWidget.instance 18 | raise ValueError("No metadata support for metadata tests") 19 | 20 | 21 | @bdd.then("the metadata widget should be visible") 22 | def check_metadata_widget_visible(metadatawidget): 23 | assert metadatawidget.isVisible() 24 | 25 | 26 | @bdd.then("the metadata widget should not be visible") 27 | def check_metadata_widget_not_visible(metadatawidget): 28 | assert not metadatawidget.isVisible() 29 | 30 | 31 | @bdd.then(bdd.parsers.parse("the metadata text should contain '{text}'")) 32 | def check_text_in_metadata(metadatawidget, text): 33 | assert text in metadatawidget.text() 34 | 35 | 36 | @bdd.then(bdd.parsers.parse("the metadata text should not contain '{text}'")) 37 | def check_text_not_in_metadata(metadatawidget, text): 38 | assert text not in metadatawidget.text() 39 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_multidirectory_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("multidirectory.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_slideshow_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv import api 7 | from vimiv.imutils import slideshow 8 | 9 | 10 | bdd.scenarios("slideshow.feature") 11 | 12 | 13 | @pytest.fixture 14 | def sshow(): 15 | instance = slideshow._timer 16 | yield instance 17 | instance.stop() 18 | 19 | 20 | @bdd.given(bdd.parsers.parse("I forcefully set the slideshow delay to {delay:d}ms")) 21 | def set_slideshow_delay(sshow, delay): 22 | """Set the slideshow delay to a small value to increase test speed.""" 23 | sshow.setInterval(delay) 24 | 25 | 26 | @bdd.then("the slideshow should be playing") 27 | def check_slideshow_playing(sshow): 28 | assert sshow.isActive() 29 | 30 | 31 | @bdd.then("the slideshow should not be playing") 32 | def check_slideshow_not_playing(sshow): 33 | assert not sshow.isActive() 34 | 35 | 36 | @bdd.then(bdd.parsers.parse("the slideshow delay should be {delay:f}")) 37 | def check_slideshow_delay(sshow, delay): 38 | # Check setting 39 | assert api.settings.slideshow.delay.value == delay 40 | # Check actual value 41 | assert sshow.interval() == delay * 1000 42 | 43 | 44 | @bdd.when(bdd.parsers.parse("I let the slideshow run {repeat:d} times")) 45 | def wait_slideshow_signal(qtbot, sshow, repeat): 46 | for _ in range(repeat): 47 | # Wait for slideshow delay and give it a small buffer 48 | with qtbot.waitSignal(sshow.timeout, timeout=int(sshow.interval() * 1.2)): 49 | pass 50 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_svg_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("svg.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/image/test_write_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | try: 6 | import piexif 7 | except ImportError: 8 | piexif = None 9 | 10 | 11 | bdd.scenarios("write.feature") 12 | 13 | 14 | @bdd.when(bdd.parsers.parse("I write the image to {name}")) 15 | def write_image(handler, name): 16 | handler.write_pixmap( 17 | handler._edit_handler.pixmap, 18 | path=name, 19 | original_path=handler._path, 20 | parallel=False, 21 | ) 22 | 23 | 24 | @bdd.then(bdd.parsers.parse("the image {name} should contain exif information")) 25 | def check_exif_information(exif_content, name): 26 | exif_dict = piexif.load(name) 27 | for ifd, ifd_dict in exif_content.items(): 28 | for key, value in ifd_dict.items(): 29 | assert exif_dict[ifd][key] == value 30 | -------------------------------------------------------------------------------- /tests/end2end/features/image/write.feature: -------------------------------------------------------------------------------- 1 | Feature: Write an image to disk 2 | 3 | Scenario Outline: Write image to new path 4 | Given I open any image 5 | When I write the image to 6 | Then the file should exist 7 | 8 | Examples: 9 | | name | 10 | | new_path.jpg | 11 | | new_path.png | 12 | | new_path.tiff | 13 | 14 | @metadata 15 | Scenario: Write image preserving exif information 16 | Given I open any image 17 | When I add exif information 18 | And I write the image to new_path.jpg 19 | Then the image new_path.jpg should contain exif information 20 | 21 | Scenario: Prompt for writing edited image 22 | Given I open any image 23 | When I run rotate 24 | And I plan to answer the prompt with n 25 | And I run next 26 | Then no crash should happen 27 | 28 | Scenario: Crash when writing image with tilde in path 29 | Given I open any image 30 | When I write the image to ~/test.jpg 31 | Then no crash should happen 32 | And the home directory should contain test.jpg 33 | -------------------------------------------------------------------------------- /tests/end2end/features/library/library.feature: -------------------------------------------------------------------------------- 1 | Feature: Miscellaneous features connected to the library 2 | 3 | Background: 4 | Given I open a directory with 2 paths 5 | 6 | Scenario: Hide hidden files 7 | When I create the directory '.hidden' 8 | And I reload the library 9 | Then the library should contain 2 paths 10 | 11 | Scenario: Show hidden files 12 | When I create the directory '.hidden' 13 | And I reload the library 14 | And I run set library.show_hidden true 15 | Then the library should contain 3 paths 16 | # .hidden is placed in front of the current selection 17 | And the library row should be 2 18 | 19 | Scenario: Invert directory order in the library 20 | When I run set sort.reverse! 21 | # We change the order to 22 | # - child_02 23 | # - child_01 24 | # but keep the selection on child_01 due to the stored position 25 | Then the library row should be 2 26 | -------------------------------------------------------------------------------- /tests/end2end/features/library/libraryresize.feature: -------------------------------------------------------------------------------- 1 | Feature: Resize the library. 2 | 3 | Background: 4 | Given I open any directory 5 | 6 | Scenario: Resize the library when the main window changes width. 7 | When I resize the window to 400x600 8 | Then the library width should be 0.3 9 | 10 | Scenario: Increase the library size. 11 | When I run set library.width +0.1 12 | Then the library width should be 0.4 13 | 14 | Scenario: Decrease the library size. 15 | When I run set library.width -0.1 16 | Then the library width should be 0.2 17 | 18 | Scenario: Decrease to the minimum size. 19 | When I run set library.width 0 20 | Then the library width should be 0.05 21 | 22 | Scenario: Increase to maximum size. 23 | When I run set library.width 1 24 | Then the library width should be 0.95 25 | 26 | Scenario: Increase and reset to default size. 27 | When I run set library.width 1 28 | And I run set library.width 29 | Then the library width should be 0.3 30 | -------------------------------------------------------------------------------- /tests/end2end/features/library/test_library_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("library.feature") 7 | 8 | 9 | @bdd.when("I reload the library") 10 | def reload_library(library): 11 | library._open_directory(".", reload_current=True) 12 | 13 | 14 | @bdd.then(bdd.parsers.parse("the library should contain {n_paths:d} paths")) 15 | def check_library_paths(library, n_paths): 16 | assert len(library.pathlist()) == n_paths 17 | -------------------------------------------------------------------------------- /tests/end2end/features/library/test_libraryresize_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv import api 7 | 8 | 9 | bdd.scenarios("libraryresize.feature") 10 | 11 | 12 | @bdd.then(bdd.parsers.parse("the library width should be {fraction:f}")) 13 | def check_library_size(library, mainwindow, fraction, qtbot): 14 | # Check if setting was updated 15 | assert api.settings.library.width.value == pytest.approx(fraction) 16 | # Check if width fits fraction of main window 17 | real_fraction = library.width() / mainwindow.width() 18 | assert fraction == real_fraction 19 | -------------------------------------------------------------------------------- /tests/end2end/features/library/test_libraryscroll_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import math 4 | 5 | import pytest_bdd as bdd 6 | 7 | from vimiv.utils import quotedjoin 8 | 9 | 10 | bdd.scenarios("libraryscroll.feature") 11 | 12 | 13 | @bdd.then(bdd.parsers.parse("the library should be {num} page {direction}")) 14 | @bdd.then(bdd.parsers.parse("the library should be {num} pages {direction}")) 15 | def check_library_page(library, num, direction): 16 | nums = {"one": 1, "half a": 0.5, "two": 2} 17 | assert num in nums, f"Invalid num '{num}'. Must be one of {quotedjoin(nums)}" 18 | multiplier = nums[num] 19 | pagesize = _get_pagesize(library) 20 | scrollstep = pagesize - 1 21 | expected_row = math.ceil(scrollstep * multiplier) 22 | row = library.row() 23 | assert row == expected_row 24 | 25 | 26 | def _get_pagesize(library): 27 | """Simple implementation to get the library pagesize. 28 | 29 | Only works if there is no empty space in the viewport. 30 | """ 31 | view_height = library.viewport().height() 32 | row_height = library.visualRect(library.model().index(1, 0)).height() 33 | n_rows = math.ceil(view_height / row_height) 34 | return n_rows 35 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/clipboard.feature: -------------------------------------------------------------------------------- 1 | Feature: Interaction with the system clipboard. 2 | 3 | Scenario: Copy basename from library path to clipboard. 4 | Given I open a directory with 1 paths 5 | When I run copy-name 6 | Then the clipboard should contain 'child_01' 7 | 8 | Scenario: Copy basename from library path to primary. 9 | Given I open a directory with 1 paths 10 | When I run copy-name --primary 11 | Then the primary selection should contain 'child_01' 12 | 13 | Scenario: Copy abspath from library path to clipboard. 14 | Given I open a directory with 1 paths 15 | When I run copy-name --abspath 16 | # /tmp from the directory in which tests are run 17 | Then the absolute path of child_01 should be saved in the clipboard 18 | 19 | Scenario: Copy basename from image path to clipboard. 20 | Given I open any image 21 | When I run copy-name 22 | Then the clipboard should contain 'image.jpg' 23 | 24 | Scenario: Copy and paste basename from library 25 | Given I open a directory with 1 paths 26 | When I run copy-name 27 | And I run paste-name 28 | Then the working directory should be child_01 29 | 30 | Scenario: Copy image to clipboard. 31 | Given I open any image 32 | When I run copy-image 33 | Then the clipboard should contain any image 34 | 35 | Scenario: Copy image to primary. 36 | Given I open any image 37 | When I run copy-image --primary 38 | Then the primary selection should contain any image 39 | 40 | Scenario: Copy image to clipboard and scale width. 41 | Given I open any image 42 | When I run copy-image --width=100 43 | Then the clipboard should contain an image with width 100 44 | 45 | Scenario: Copy image to clipboard and scale height. 46 | Given I open any image 47 | When I run copy-image --height=100 48 | Then the clipboard should contain an image with height 100 49 | 50 | Scenario: Copy image to clipboard and scale size. 51 | Given I open any image 52 | When I run copy-image --size=100 53 | Then the clipboard should contain an image with size 100 54 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | from vimiv.qt.gui import QGuiApplication, QClipboard 7 | 8 | 9 | @pytest.fixture() 10 | def clipboard(): 11 | return QGuiApplication.clipboard() 12 | 13 | 14 | @bdd.then(bdd.parsers.parse("The clipboard should contain '{text}'")) 15 | def check_clipboard(clipboard, text): 16 | assert text in clipboard.text(mode=QClipboard.Mode.Clipboard) 17 | 18 | 19 | @bdd.then(bdd.parsers.parse("The primary selection should contain '{text}'")) 20 | def check_primary(clipboard, text): 21 | assert text in clipboard.text(mode=QClipboard.Mode.Selection) 22 | 23 | 24 | @bdd.then(bdd.parsers.parse("The clipboard should contain any image")) 25 | def check_clipboard_image(clipboard, image): 26 | assert not clipboard.pixmap(mode=QClipboard.Mode.Clipboard).toImage().isNull() 27 | 28 | 29 | @bdd.then(bdd.parsers.parse("The primary selection should contain any image")) 30 | def check_primary_image(clipboard, image): 31 | assert not clipboard.pixmap(mode=QClipboard.Mode.Selection).toImage().isNull() 32 | 33 | 34 | @bdd.then(bdd.parsers.parse("The clipboard should contain an image with width {width}")) 35 | def check_clipboard_image_width(clipboard, width): 36 | assert clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().width() == int(width) 37 | 38 | 39 | @bdd.then( 40 | bdd.parsers.parse("The clipboard should contain an image with height {height}") 41 | ) 42 | def check_clipboard_image_height(clipboard, height): 43 | assert clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().height() == int( 44 | height 45 | ) 46 | 47 | 48 | @bdd.then(bdd.parsers.parse("The clipboard should contain an image with size {size}")) 49 | def check_clipboard_image_size(clipboard, size): 50 | assert max( 51 | clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().height(), 52 | clipboard.pixmap(mode=QClipboard.Mode.Clipboard).size().width(), 53 | ) == int(size) 54 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/fullscreen.feature: -------------------------------------------------------------------------------- 1 | Feature: Toggle fullscreen mode. 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Enter fullscreen mode. 7 | When I run fullscreen 8 | Then the window should be fullscreen 9 | 10 | Scenario: Enter and leave fullscreen mode. 11 | When I run fullscreen 12 | And I run fullscreen 13 | Then the window should not be fullscreen 14 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/keybindings_popup.feature: -------------------------------------------------------------------------------- 1 | Feature: Keybindings command with pop up window with the keybindings for current mode 2 | 3 | Scenario: Display the keybindings pop up for library mode. 4 | Given I start vimiv 5 | When I run keybindings 6 | Then the keybindings pop up should contain 'set library.width' 7 | And the pop up 'vimiv - keybindings' should be displayed 8 | 9 | Scenario: Display the keybindings pop up for image mode. 10 | Given I open 2 images 11 | When I run keybindings 12 | Then the keybindings pop up should contain 'flip' 13 | And the pop up 'vimiv - keybindings' should be displayed 14 | 15 | Scenario: Display the keybindings pop up for thumbnail mode. 16 | Given I open 2 images 17 | When I enter thumbnail mode 18 | And I run keybindings 19 | Then the keybindings pop up should contain 'zoom in' 20 | And the pop up 'vimiv - keybindings' should be displayed 21 | 22 | Scenario: Display the keybindings pop up for manipulate mode. 23 | Given I open 2 images 24 | When I enter manipulate mode 25 | And I run keybindings 26 | Then the keybindings pop up should contain 'accept' 27 | And the pop up 'vimiv - keybindings' should be displayed 28 | 29 | Scenario: Search the keybindins pop up 30 | Given I start vimiv 31 | When I run keybindings 32 | And I press 'comm' in the pop up 33 | Then 'comm' should be highlighted in 'command' 34 | Then the keybindings pop up should describe 'command' 35 | 36 | Scenario: Do not describe single command matches 37 | Given I start vimiv 38 | When I run keybindings 39 | And I press 'a' in the pop up 40 | Then the keybindings pop up description should be empty 41 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/keyhint.feature: -------------------------------------------------------------------------------- 1 | Feature: The keyhint overlay widget 2 | 3 | Background: 4 | Given I start vimiv 5 | 6 | Scenario: Display widget on partial keybindings 7 | When I press 'g' 8 | And I wait for the keyhint widget 9 | Then the keyhint widget should be visible 10 | 11 | Scenario: Keyhint widget contains partial matches 12 | When I press 'g' 13 | Then the keyhint widget should contain goto 1 14 | And the keyhint widget should contain enter image 15 | 16 | Scenario: Keyhint widget cleared and hidden after timeout 17 | When I press 'g' 18 | And I wait for the keyhint widget 19 | And I wait for the keyhint widget timeout 20 | Then the keyhint widget should not be visible 21 | 22 | Scenario: Keyhint should be above statusbar 23 | When I press 'g' 24 | And I wait for the keyhint widget 25 | Then the keyhint widget should be above the statusbar 26 | 27 | Scenario: Keyhint at bottom with statusbar hidden 28 | When I run set statusbar.show false 29 | And I press 'g' 30 | And I wait for the keyhint widget 31 | Then the keyhint widget should be at the bottom 32 | 33 | Scenario: Do not show widget in command mode 34 | When I run command 35 | And I press '<' 36 | Then the keyhint widget should not appear 37 | 38 | Scenario: Keyhint widget cleared and hidden after escape 39 | When I press 'g' 40 | And I wait for the keyhint widget 41 | And I press '' 42 | Then the keyhint widget should not be visible 43 | 44 | Scenario: Keyhint widget displays partial matches of special keys 45 | When I run bind g log info space 46 | And I run bind g log info tab 47 | And I press 'g' 48 | Then the keyhint widget should contain <space> 49 | And the keyhint widget should contain <tab> 50 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/migration_popup.feature: -------------------------------------------------------------------------------- 1 | Feature: Welcome-to-qt command with pop up window 2 | 3 | Scenario: Show welcome pop up 4 | Given I start vimiv 5 | When I run welcome-to-qt 6 | Then the pop up 'vimiv - welcome to qt' should be displayed 7 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/no_optional.feature: -------------------------------------------------------------------------------- 1 | Feature: Ensure the application works correctly without optional dependencies 2 | 3 | @nometadata 4 | Scenario: No metadata command 5 | Given I open any image 6 | When I run metadata 7 | Then the message 8 | 'metadata: unknown command for mode image' 9 | should be displayed 10 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/startup.feature: -------------------------------------------------------------------------------- 1 | Feature: Startup vimiv with various flags 2 | 3 | Scenario: Set an option using the commandline 4 | Given I start vimiv with -s completion.fuzzy true 5 | Then the boolean setting 'completion.fuzzy' should be 'true' 6 | 7 | Scenario: Set multiple options using the commandline 8 | Given I start vimiv with -s completion.fuzzy true -s sort.shuffle true 9 | Then the boolean setting 'completion.fuzzy' should be 'true' 10 | And the boolean setting 'sort.shuffle' should be 'true' 11 | 12 | Scenario: Set an unknown option using the commandline 13 | Given I start vimiv with -s not.a.setting true 14 | Then no crash should happen 15 | 16 | Scenario: Set a wrong option value using the commandline 17 | Given I start vimiv with -s completion.fuzzy 42 18 | Then no crash should happen 19 | 20 | Scenario: Print version information 21 | Given I capture output 22 | And I run vimiv --version 23 | Then the version information should be displayed 24 | 25 | Scenario Outline: Set log level 26 | Given I start vimiv with --log-level 27 | Then the log level should be 28 | 29 | Examples: 30 | | level | 31 | | debug | 32 | | info | 33 | | warning | 34 | | error | 35 | | critical | 36 | 37 | Scenario: Open hidden image upon startup 38 | Given I open the image '.hidden.jpg' 39 | Then the filelist should contain 1 images 40 | 41 | Scenario: Pipe paths to vimiv 42 | Given I start vimiv passing 3 images via stdin 43 | Then the filelist should contain 3 images 44 | 45 | Scenario: Start with an invalid file 46 | Given I open a text file 47 | Then no crash should happen 48 | And the mode should be library 49 | And the filelist should contain 0 images 50 | 51 | Scenario: Read binary image from stdin 52 | Given I start vimiv passing a binary image via stdin 53 | Then the mode should be image 54 | And the filelist should contain 1 images 55 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/symlink.feature: -------------------------------------------------------------------------------- 1 | Feature: Handling symbolic links 2 | 3 | Scenario: Open real path when opening symlink 4 | Given I open the symlink test directory 5 | When I run open lnstem 6 | Then the working directory should be stem 7 | 8 | Scenario: Crash when searching in symlinked directory 9 | Given I open the symlink test directory 10 | When I run open lnstem 11 | And I run search 12 | And I press 'k' 13 | Then no crash should happen 14 | 15 | Scenario: Do not load path pointed to by symlink into filelist 16 | Given I open the symlink image test directory 17 | When I run open-selected --close 18 | Then the filelist should contain 1 images 19 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/teardown.feature: -------------------------------------------------------------------------------- 1 | Feature: Various features related to teardown 2 | 3 | Scenario: Print text to stdout upon quit 4 | Given I start vimiv with -o vimiv 5 | And I capture output 6 | When I quit the application 7 | Then stdout should contain 'vimiv' 8 | 9 | Scenario: Print current path to stdout upon quit 10 | Given I open 2 images with -o % 11 | And I capture output 12 | When I quit the application 13 | Then stdout should contain 'image_01.jpg' 14 | 15 | Scenario: Print marked images to stdout upon quit 16 | Given I open 2 images with -o %m 17 | And I capture output 18 | When I run mark * 19 | And I quit the application 20 | Then stdout should contain 'image_01.jpg' 21 | And stdout should contain 'image_02.jpg' 22 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_clipboard_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import os 4 | 5 | import pytest_bdd as bdd 6 | 7 | from vimiv.qt.gui import QClipboard 8 | 9 | 10 | bdd.scenarios("clipboard.feature") 11 | 12 | 13 | @bdd.then( 14 | bdd.parsers.parse( 15 | # Need a slightly different wording so it cannot overlap with the previous 16 | # versions 17 | "the absolute path of {text} should be saved in the clipboard" 18 | ) 19 | ) 20 | def check_clipboard_abspath(clipboard, text): 21 | text = os.path.abspath(text) 22 | assert clipboard.text(mode=QClipboard.Mode.Clipboard) == text 23 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_fullscreen_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("fullscreen.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_keybindings_popup_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest 4 | import pytest_bdd as bdd 5 | 6 | import vimiv.gui.keybindings_popup 7 | from vimiv import api, utils 8 | 9 | bdd.scenarios("keybindings_popup.feature") 10 | 11 | 12 | @pytest.fixture() 13 | def keybindings_popup(mainwindow): 14 | """Fixture to retrieve the current keybindings pop-up.""" 15 | for widget in mainwindow.children(): 16 | if isinstance(widget, vimiv.gui.keybindings_popup.KeybindingsPopUp): 17 | return widget 18 | raise AssertionError("No keybindings pop-up open") 19 | 20 | 21 | @bdd.when(bdd.parsers.parse("I press '{keys}' in the pop up")) 22 | def press_keys_popup(keypress, keybindings_popup, keys): 23 | keypress(keybindings_popup._search, keys) 24 | 25 | 26 | @bdd.then(bdd.parsers.parse("the keybindings pop up should contain '{text}'")) 27 | def check_keybindings_popup_text(keybindings_popup, text): 28 | assert text in keybindings_popup.text 29 | 30 | 31 | @bdd.then(bdd.parsers.parse("the keybindings pop up should describe '{command}'")) 32 | def check_keybindings_popup_description(keybindings_popup, command): 33 | description = api.commands.get(command, api.modes.current()).description 34 | assert description in keybindings_popup.description 35 | 36 | 37 | @bdd.then("the keybindings pop up description should be empty") 38 | def check_keybindings_popup_description_empty(keybindings_popup): 39 | assert not keybindings_popup.description 40 | 41 | 42 | @bdd.then(bdd.parsers.parse("'{search}' should be highlighted in '{command}'")) 43 | def check_keybindings_popup_highlighting(keybindings_popup, search, command): 44 | highlight = keybindings_popup.highlighted_search_str(search) 45 | # Ensure the search_str is the actual highlighted string 46 | assert utils.strip_html(highlight) == search 47 | # Ensure the highlighted command is in the pop up text 48 | assert command.replace(search, highlight) in keybindings_popup.text 49 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_migration_popup_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("migration_popup.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_no_optional_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | bdd.scenarios("no_optional.feature") 6 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_startup_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import logging 4 | 5 | import pytest_bdd as bdd 6 | 7 | import vimiv 8 | from vimiv.utils import log 9 | 10 | 11 | bdd.scenarios("startup.feature") 12 | 13 | 14 | @bdd.then("the version information should be displayed") 15 | def check_version_information(output): 16 | assert vimiv.__name__ in output.out 17 | assert vimiv.__version__ in output.out 18 | 19 | 20 | @bdd.then(bdd.parsers.parse("the log level should be {level}")) 21 | def check_log_level(level): 22 | loglevel = getattr(logging, level.upper()) 23 | assert log._app_logger.level == loglevel 24 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_symlink_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv.qt.gui import QPixmap 6 | 7 | from vimiv import startup 8 | 9 | 10 | bdd.scenarios("symlink.feature") 11 | 12 | 13 | @bdd.given("I open the symlink test directory") 14 | def open_symlink_test_directory(tmp_path): 15 | """Create a test directory with symlink(s) and open the base directory in vimiv. 16 | 17 | Structure: 18 | ├── lnstem -> stem 19 | └── stem 20 | └── leaf 21 | """ 22 | base_directory = tmp_path / "directory" 23 | stem_directory = base_directory / "stem" 24 | leaf_directory = stem_directory / "leaf" 25 | leaf_directory.mkdir(parents=True) 26 | stem_symlink = base_directory / "lnstem" 27 | stem_symlink.symlink_to(stem_directory) 28 | argv = [str(base_directory)] 29 | args = startup.setup_pre_app(argv) 30 | startup.setup_post_app(args) 31 | 32 | 33 | @bdd.given("I open the symlink image test directory") 34 | def open_symlink_image_test_directory(tmp_path): 35 | """Create a test directory with symlink(s) and open the child directory in vimiv. 36 | 37 | Structure: 38 | ├── image.jpg 39 | └── child 40 | └── ln.jpg -> ../image.jpg 41 | """ 42 | base_directory = tmp_path / "directory" 43 | base_image_path = base_directory / "image.jpg" 44 | child_directory = base_directory / "child" 45 | child_image_path = child_directory / "ln.jpg" 46 | child_directory.mkdir(parents=True) 47 | QPixmap(300, 300).save(str(base_image_path)) 48 | child_image_path.symlink_to(base_image_path) 49 | argv = [str(child_directory)] 50 | args = startup.setup_pre_app(argv) 51 | startup.setup_post_app(args) 52 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_teardown_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("teardown.feature") 7 | 8 | 9 | @bdd.when("I quit the application") 10 | def quit_application(qapp): 11 | qapp.aboutToQuit.emit() 12 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/test_version_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("version.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/misc/version.feature: -------------------------------------------------------------------------------- 1 | Feature: Version command with pop up window. 2 | 3 | Scenario: Display the version pop up. 4 | Given I start vimiv 5 | When I run version 6 | Then the pop up 'vimiv - version' should be displayed 7 | 8 | Scenario: Copy version information to clipboard. 9 | Given I start vimiv 10 | When I run version --copy 11 | Then the clipboard should contain 'vimiv' 12 | -------------------------------------------------------------------------------- /tests/end2end/features/plugins/plugins.feature: -------------------------------------------------------------------------------- 1 | Feature: Plugin system with default plugins 2 | 3 | Scenario: Fail print command without image 4 | Given I open any directory 5 | When I enter image mode 6 | And I run print 7 | Then the message 8 | 'print: No widget to print' 9 | should be displayed 10 | 11 | @flaky 12 | Scenario: Show print dialog 13 | Given I open any image 14 | When I run print 15 | Then the pop up 'Print' should be displayed 16 | 17 | @flaky 18 | Scenario: Show print preview dialog 19 | Given I open any image 20 | When I run print --preview 21 | Then the pop up 'Print Preview' should be displayed 22 | 23 | Scenario: Load cr2 support 24 | Given I start vimiv 25 | When I load the imageformats plugin with cr2 26 | Then the cr2 format should be supported 27 | 28 | Scenario: Load demo plugin 29 | Given I start vimiv 30 | And I capture output 31 | When I load the demo plugin with hello demo 32 | And I run hello-world 33 | Then stdout should contain 'Initializing demo plugin with 'hello demo'' 34 | And stdout should contain 'Hello world' 35 | -------------------------------------------------------------------------------- /tests/end2end/features/plugins/test_plugins_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import plugins 6 | from vimiv.utils import imageheader 7 | 8 | 9 | bdd.scenarios("plugins.feature") 10 | 11 | 12 | @bdd.when(bdd.parsers.parse("I load the {name} plugin with {info}")) 13 | def load_plugin(name, info): 14 | plugins._load_plugin(name, info, plugins._app_plugin_directory) 15 | 16 | 17 | @bdd.then(bdd.parsers.parse("The {name} format should be supported")) 18 | def check_format_supported(name): 19 | assert name in [ 20 | filetype for filetype, _ in imageheader._registry 21 | ], f"Image format {name} is not supported" 22 | -------------------------------------------------------------------------------- /tests/end2end/features/statusbar/message.feature: -------------------------------------------------------------------------------- 1 | Feature: Push messages to the statusbar. 2 | 3 | Background: 4 | Given I open any directory 5 | 6 | Scenario: Display warning message. 7 | When I log the warning 'this is a warning' 8 | Then the message 9 | 'this is a warning' 10 | should be displayed 11 | 12 | Scenario: Hide message widget when clearing status 13 | When I log the warning 'this is a warning' 14 | And I clear the status 15 | Then no message should be displayed 16 | 17 | Scenario: Clear message after key press 18 | When I log the warning 'this is a warning' 19 | And I press '0' 20 | Then no message should be displayed 21 | -------------------------------------------------------------------------------- /tests/end2end/features/statusbar/status.feature: -------------------------------------------------------------------------------- 1 | Feature: Display status information in the statusbar 2 | 3 | Scenario: Display image information 4 | Given I open 3 images 5 | Then the left status should include 1/3 6 | And the left status should include image_01.jpg 7 | And the right status should include IMAGE 8 | 9 | Scenario: Display library information 10 | Given I open any directory 11 | Then the left status should include directory 12 | And the right status should include LIBRARY 13 | 14 | Scenario: Display correct mode after switch 15 | Given I open any directory 16 | When I run enter image 17 | Then the right status should include IMAGE 18 | 19 | Scenario: Show filesize in statusbar 20 | Given I start vimiv 21 | When I run set statusbar.left {filesize} 22 | # No current path selected 23 | Then the left status should include N/A 24 | 25 | Scenario: Do not crash when showing filesize in command moe 26 | Given I start vimiv 27 | When I run set statusbar.left {filesize} 28 | And I run command 29 | Then no crash should happen 30 | 31 | Scenario: Correctly escape html for keybindings 32 | Given I start vimiv 33 | When I run bind << scroll down 34 | And I press '<' 35 | Then the right status should include < 36 | 37 | Scenario: Show read-only mode in statusbar 38 | Given I start vimiv 39 | When I run set read_only true 40 | Then the left status should include [RO] 41 | 42 | Scenario: Show cursor position in statusbar 43 | Given I open any image 44 | When I run set statusbar.right_image {cursor-position} 45 | Then the image should have mouse tracking 46 | -------------------------------------------------------------------------------- /tests/end2end/features/statusbar/test_message_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | from vimiv.utils import log 7 | 8 | 9 | bdd.scenarios("message.feature") 10 | 11 | 12 | @bdd.when(bdd.parsers.parse("I log the warning '{message}'")) 13 | def log_warning(message, qtbot): 14 | log.warning(message) 15 | 16 | 17 | @bdd.when("I clear the status") 18 | def clear_status(): 19 | api.status.clear("clear in bdd step") 20 | -------------------------------------------------------------------------------- /tests/end2end/features/statusbar/test_status_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("status.feature") 7 | 8 | 9 | @bdd.then("the image should have mouse tracking") 10 | def check_image_tracks_mouse(image): 11 | assert image.hasMouseTracking() 12 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/.zcompdump: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlch/vimiv-qt/cbce18b2d36da890608ad57388482cc003d3ce03/tests/end2end/features/thumbnail/.zcompdump -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | @bdd.then(bdd.parsers.parse("there should be {number:d} thumbnails")) 7 | def check_thumbnail_amount(thumbnail, number): 8 | assert thumbnail.model().rowCount() == number 9 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/test_thumbnailgoto_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("thumbnailgoto.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/test_thumbnailmark_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("thumbnailmark.feature") 7 | 8 | 9 | @bdd.then(bdd.parsers.parse("the thumbnail number {number:d} should be marked")) 10 | def check_thumbnail_marked(thumbnail, number): 11 | item = thumbnail.item(number - 1) 12 | assert item is not None and item.marked 13 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/test_thumbnailscroll_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | 6 | bdd.scenarios("thumbnailscroll.feature") 7 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/test_thumbnailzoom_bdd.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | import pytest_bdd as bdd 4 | 5 | from vimiv import api 6 | 7 | 8 | bdd.scenarios("thumbnailzoom.feature") 9 | 10 | 11 | @bdd.then(bdd.parsers.parse("the thumbnail size should be {size:d}")) 12 | def check_thumbnail_size(thumbnail, size): 13 | # Check setting 14 | assert api.settings.thumbnail.size.value == size 15 | # Check actual value 16 | assert thumbnail.iconSize().width() == size 17 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/thumbnailgoto.feature: -------------------------------------------------------------------------------- 1 | Feature: Select a specific thumbnail using the :goto command. 2 | 3 | Scenario: Select last thumbnail 4 | Given I open 10 images 5 | And I enter thumbnail mode 6 | When I run goto -1 7 | Then the thumbnail number 10 should be selected 8 | 9 | Scenario: Select last and then first thumbnail 10 | Given I open 10 images 11 | And I enter thumbnail mode 12 | When I run goto -1 13 | And I run goto 1 14 | Then the thumbnail number 1 should be selected 15 | 16 | Scenario: Select specific thumbnail using count 17 | Given I open 10 images 18 | And I enter thumbnail mode 19 | When I run 3goto 20 | Then the thumbnail number 3 should be selected 21 | 22 | Scenario: Crash on goto with empty thumbnail list 23 | Given I start vimiv 24 | When I enter thumbnail mode 25 | And I run goto 4 26 | Then no crash should happen 27 | And the message 28 | 'goto: No thumbnail in list' 29 | should be displayed 30 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/thumbnailmark.feature: -------------------------------------------------------------------------------- 1 | Feature: Mark thumbnails 2 | 3 | Background: 4 | Given I open 5 images 5 | And I enter thumbnail mode 6 | 7 | Scenario: Mark a thumbnail 8 | When I run mark % 9 | Then the thumbnail number 1 should be marked 10 | 11 | Scenario: Keep correct highlighting when restoring thumbnails 12 | When I run mark image_03.jpg 13 | And I run mark image_05.jpg 14 | And I run delete image_04.jpg 15 | And I wait for the working directory handler 16 | And I run undelete image_04.jpg 17 | And I wait for the working directory handler 18 | Then the thumbnail number 3 should be marked 19 | And the thumbnail number 5 should be marked 20 | -------------------------------------------------------------------------------- /tests/end2end/features/thumbnail/thumbnailzoom.feature: -------------------------------------------------------------------------------- 1 | Feature: Zoom thumbnails. 2 | 3 | Background: 4 | Given I open any image 5 | And I enter thumbnail mode 6 | 7 | Scenario: Increase thumbnail size. 8 | When I run zoom in 9 | Then the thumbnail size should be 256 10 | 11 | Scenario: Decrease thumbnail size. 12 | When I run zoom out 13 | Then the thumbnail size should be 64 14 | 15 | Scenario: Try to increase thumbnail size below limit. 16 | # 256 17 | When I run zoom in 18 | # 512 19 | And I run zoom in 20 | # 512 21 | And I run zoom in 22 | Then the thumbnail size should be 512 23 | 24 | Scenario: Try to decrease thumbnail size below limit. 25 | # 64 26 | When I run zoom out 27 | # 64 28 | And I run zoom out 29 | Then the thumbnail size should be 64 30 | -------------------------------------------------------------------------------- /tests/end2end/mockdecorators.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Module to patch vimiv decorators before import.""" 4 | 5 | import contextlib 6 | import unittest.mock 7 | 8 | _known_classes = set() 9 | 10 | 11 | def mockregister(component_init): 12 | """Patch api.objreg.register to additionally store the known classes.""" 13 | 14 | def inside(component, *args, **kwargs) -> None: 15 | component.__class__.instance = component 16 | _known_classes.add(component.__class__) 17 | component_init(component, *args, **kwargs) 18 | 19 | return inside 20 | 21 | 22 | def mockregister_cleanup(): 23 | for cls in _known_classes: 24 | # Instances stored as a global variable 25 | if cls.__qualname__ not in ("Mark", "ExternalRunner"): 26 | cls.instance = None 27 | 28 | 29 | @contextlib.contextmanager 30 | def apply(): 31 | """Contextmanager to mock relevant vimiv decorators and restore state.""" 32 | with unittest.mock.patch("vimiv.api.objreg.register", mockregister): 33 | yield 34 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Integration tests.""" 4 | -------------------------------------------------------------------------------- /tests/integration/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Common fixtures for integration testing.""" 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture() 9 | def custom_configparser(): 10 | """Fixture to create a custom configparser adding to defaults.""" 11 | 12 | def create_custom_parser(default_parser, **sections): 13 | parser = default_parser() 14 | for section, content in sections.items(): 15 | for key, value in content.items(): 16 | parser[section][key] = value 17 | return parser 18 | 19 | return create_custom_parser 20 | 21 | 22 | @pytest.fixture() 23 | def custom_configfile(tmp_path, custom_configparser): 24 | """Fixture to create a custom config file from a configparser.""" 25 | 26 | def create_custom_configfile(basename, read, default_parser, **sections): 27 | parser = custom_configparser(default_parser, **sections) 28 | path = tmp_path / basename 29 | with open(path, "w", encoding="utf-8") as f: 30 | parser.write(f) 31 | read(str(path)) 32 | 33 | return create_custom_configfile 34 | -------------------------------------------------------------------------------- /tests/integration/test_app.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Integration tests for vimiv.app. 4 | 5 | These are not unit tests as the app makes use of multiple modules.""" 6 | 7 | import time 8 | 9 | import pytest 10 | 11 | import vimiv.app 12 | from vimiv import qt 13 | from vimiv.utils import asyncrun 14 | 15 | 16 | @pytest.mark.ci_skip 17 | def test_load_icon(qtbot): 18 | icon = vimiv.app.Application.get_icon() 19 | assert not icon.isNull() 20 | 21 | 22 | @pytest.mark.xfail(qt.USE_PYQT5, reason="flaky under PyQt 5.15.10") 23 | def test_wait_for_running_processes(mocker): 24 | """Ensure any running threads are completed before the app exits.""" 25 | 26 | def process(): 27 | time.sleep(0.001) 28 | callback() 29 | 30 | callback = mocker.Mock() 31 | asyncrun(process) 32 | vimiv.app.Application.preexit(0) 33 | callback.assert_called_once() 34 | -------------------------------------------------------------------------------- /tests/integration/test_edit.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Integration tests related to editing images.""" 4 | 5 | import pytest 6 | 7 | from vimiv.qt.gui import QPixmap, QColor 8 | 9 | from vimiv.config import styles 10 | from vimiv.imutils import edit_handler 11 | 12 | 13 | WIDTH = 300 14 | HEIGHT = 200 15 | COLOR = QColor("#888888") 16 | 17 | 18 | @pytest.fixture() 19 | def edit(mocker, qtbot): 20 | mocker.patch("vimiv.api.signals") 21 | mocker.patch("vimiv.api.modes.Mode.close") 22 | mocker.patch.object(styles, "_style", styles.create_default()) 23 | handler = edit_handler.EditHandler() 24 | handler._init_manipulate() 25 | handler.pixmap = QPixmap(WIDTH, HEIGHT) 26 | handler.pixmap.fill(COLOR) 27 | yield handler 28 | 29 | 30 | @pytest.fixture() 31 | def transform(edit): 32 | yield edit.transform 33 | 34 | 35 | @pytest.fixture() 36 | def manipulate(edit): 37 | edit.manipulate._enter() 38 | return edit.manipulate 39 | 40 | 41 | def current_color(pixmap): 42 | image = pixmap.toImage() 43 | return QColor(image.pixel(0, 0)) 44 | 45 | 46 | def test_transform_applied(edit, transform): 47 | transform.rotate_command() 48 | assert edit.pixmap.width() == HEIGHT 49 | assert edit.pixmap.height() == WIDTH 50 | 51 | 52 | def test_manipulate_applied(edit, manipulate): 53 | manipulate.increase(10) 54 | manipulate.accept() 55 | assert current_color(edit.pixmap) != COLOR 56 | 57 | 58 | def test_manipulate_and_transform_iteratively(edit, transform, manipulate): 59 | transform.rotate_command() 60 | manipulate.increase(10) 61 | manipulate.accept() 62 | assert edit.pixmap.width() == HEIGHT 63 | assert edit.pixmap.height() == WIDTH 64 | assert current_color(edit.pixmap) != COLOR 65 | transform.rotate_command() 66 | assert edit.pixmap.height() == HEIGHT 67 | assert edit.pixmap.width() == WIDTH 68 | -------------------------------------------------------------------------------- /tests/integration/test_metadata.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.imutils.metadata.""" 4 | 5 | import pytest 6 | 7 | from vimiv.imutils import metadata 8 | from vimiv.plugins import metadata_piexif, metadata_pyexiv2 9 | 10 | 11 | @pytest.fixture(autouse=True) 12 | def reset_to_default(cleanup_helper): 13 | """Fixture to ensure everything is reset to default after testing.""" 14 | registry = list(metadata._registry) 15 | yield 16 | metadata._registry = registry 17 | 18 | 19 | @pytest.fixture 20 | def nometadata(): 21 | metadata._registry = [] 22 | 23 | 24 | @pytest.fixture 25 | def piexif(): 26 | metadata._registry = [] 27 | metadata_piexif.init() 28 | 29 | 30 | @pytest.fixture 31 | def pyexiv2(): 32 | metadata._registry = [] 33 | metadata_pyexiv2.init() 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "methodname, args", 38 | ( 39 | ("copy_metadata", ("dest.jpg",)), 40 | ("get_date_time", ()), 41 | ("get_metadata", ([],)), 42 | ("get_keys", ()), 43 | ), 44 | ) 45 | def test_handler_raises(nometadata, methodname, args): 46 | assert not metadata.has_metadata_support() 47 | 48 | handler = metadata.MetadataHandler("path") 49 | method = getattr(handler, methodname) 50 | with pytest.raises(metadata.MetadataError): 51 | method(*args) 52 | 53 | 54 | def test_piexif_initializes(piexif): 55 | assert metadata_piexif.MetadataPiexif in metadata._registry 56 | assert metadata.has_metadata_support() 57 | 58 | 59 | def test_pyexiv2_initializes(pyexiv2): 60 | assert metadata_pyexiv2.MetadataPyexiv2 in metadata._registry 61 | assert metadata.has_metadata_support() 62 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Unit tests.""" 4 | -------------------------------------------------------------------------------- /tests/unit/api/test_objreg.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.api.objreg.""" 4 | 5 | import pytest 6 | 7 | from vimiv.api import objreg 8 | 9 | 10 | class Multiplier: 11 | """Test class to be registered in the objreg with a simple test method.""" 12 | 13 | instance = None 14 | 15 | @objreg.register 16 | def __init__(self, factor): 17 | self._factor = factor 18 | 19 | def multiply_by(self, number): 20 | return number * self._factor 21 | 22 | 23 | @pytest.fixture() 24 | def multiply_by_two(): 25 | """Fixture to create a multiplier instance that multiplies by two.""" 26 | multiplier = Multiplier(2) 27 | yield multiplier 28 | Multiplier.instance = None 29 | 30 | 31 | def test_call_with_instance(multiply_by_two): 32 | number = 42 33 | result = objreg._call_with_instance(Multiplier.multiply_by, number) 34 | assert result == 2 * number 35 | 36 | 37 | def test_call_without_instance(): 38 | def func(argument): 39 | return argument * 2 40 | 41 | argument = 42 42 | 43 | assert func(argument) == objreg._call_with_instance(func, argument) 44 | -------------------------------------------------------------------------------- /tests/unit/api/test_prompt.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.api.prompt.""" 4 | 5 | import pytest 6 | 7 | from vimiv.qt.core import QObject 8 | 9 | from vimiv.api import prompt 10 | 11 | 12 | class QuestionAnswerer(QObject): 13 | """Stub class answering an asked question with a defined value.""" 14 | 15 | def __init__(self, *, title: str, body: str, answer=None): 16 | super().__init__() 17 | self.title = title 18 | self.body = body 19 | self.answered = False 20 | self.answer = answer 21 | prompt.question_asked.connect(self.answer_question) 22 | 23 | def answer_question(self, question: prompt.Question): 24 | assert question.title == self.title 25 | assert question.body == self.body 26 | question.answer = self.answer 27 | self.answered = True 28 | 29 | 30 | @pytest.mark.parametrize("answer", (None, 42, "answer")) 31 | def test_ask_question(answer): 32 | title = "Question" 33 | body = "Does this test work?" 34 | answerer = QuestionAnswerer(title=title, body=body, answer=answer) 35 | received_answer = prompt.ask_question(title=title, body=body) 36 | assert answerer.answered 37 | assert received_answer == answer 38 | -------------------------------------------------------------------------------- /tests/unit/api/test_status.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.api.status.""" 4 | 5 | import pytest 6 | 7 | from vimiv.api import status 8 | 9 | 10 | @pytest.fixture() 11 | def dummy_module(): 12 | """Fixture to create and clean-up a valid status module.""" 13 | name = "{dummy}" 14 | content = "dummy" 15 | 16 | @status.module(name) 17 | def dummy_method(): 18 | return content 19 | 20 | yield name, dummy_method() 21 | 22 | del status._modules[name] 23 | 24 | 25 | def test_evaluate_status_module(dummy_module): 26 | name, content = dummy_module 27 | assert status.evaluate(f"Dummy: {name}") == f"Dummy: {content}" 28 | 29 | 30 | def test_fail_add_status_module(): 31 | with pytest.raises(ValueError): 32 | 33 | @status.module("wrong") 34 | def wrong(): 35 | """Status module with an invalid name.""" 36 | return "wrong" 37 | 38 | 39 | def test_evaluate_unknown_module(): 40 | name = "{unknown-module}" 41 | assert status.evaluate(f"Dummy: {name}") == "Dummy: " 42 | -------------------------------------------------------------------------------- /tests/unit/commands/test_aliases.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.commands.aliases.""" 4 | 5 | from typing import NamedTuple 6 | 7 | import pytest 8 | 9 | from vimiv import api 10 | from vimiv.commands import aliases 11 | 12 | 13 | class AliasDefinition(NamedTuple): 14 | name: str = "test" 15 | command: str = "quit" 16 | mode: api.modes.Mode = api.modes.GLOBAL 17 | 18 | 19 | @pytest.fixture(params=[api.modes.GLOBAL]) 20 | def alias(request): 21 | """Pytest fixture to create and delete an alias. 22 | 23 | Default mode for alias creation is GLOBAL. This can be changed via indirect fixture 24 | parametrization. 25 | """ 26 | mode = request.param 27 | definition = AliasDefinition(mode=mode) 28 | aliases.alias(definition.name, [definition.command], mode=definition.mode.name) 29 | yield definition 30 | del aliases._aliases[definition.mode][definition.name] 31 | 32 | 33 | def test_add_global_alias(alias): 34 | """Ensure alias added for global mode is available in all global modes.""" 35 | for mode in (*api.modes.GLOBALS, api.modes.GLOBAL): 36 | assert aliases.get(mode)[alias.name] == alias.command 37 | 38 | 39 | @pytest.mark.parametrize("alias", api.modes.GLOBALS, indirect=True) 40 | def test_add_local_alias(alias): 41 | """Ensure alias added for a single mode is only available in this mode.""" 42 | assert aliases.get(alias.mode)[alias.name] == alias.command 43 | other = set(api.modes.ALL) - {alias.mode} 44 | for mode in other: 45 | with pytest.raises(KeyError, match=alias.name): 46 | aliases.get(mode)[alias.name] # pylint: disable=expression-not-assigned 47 | 48 | 49 | def test_fail_add_alias_no_list(): 50 | with pytest.raises(AssertionError, match="defined as list"): 51 | aliases.alias("test", "any") 52 | 53 | 54 | def test_fail_mode_not_as_str(): 55 | with pytest.raises(AssertionError, match="Mode must be passed"): 56 | aliases.alias("test", ["any"], mode=api.modes.GLOBAL) 57 | -------------------------------------------------------------------------------- /tests/unit/commands/test_commands.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.commands.""" 4 | 5 | import pytest 6 | 7 | from vimiv import commands 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "number, count, max_count, expected", 12 | [ 13 | (1, None, 5, 0), 14 | (1, 2, 5, 1), 15 | (10, None, 5, 4), 16 | (-1, None, 5, 4), 17 | (0, None, 5, 0), 18 | ], 19 | ) 20 | def test_number_for_command(number, count, max_count, expected): 21 | assert commands.number_for_command(number, count, max_count=max_count) == expected 22 | 23 | 24 | def test_fail_number_for_command(): 25 | with pytest.raises(ValueError): 26 | commands.number_for_command(None, None, max_count=42) 27 | -------------------------------------------------------------------------------- /tests/unit/commands/test_history.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for commands.history.History.""" 4 | 5 | import json 6 | import os 7 | 8 | import pytest 9 | 10 | from vimiv import api 11 | from vimiv.commands.history import History 12 | 13 | 14 | MODES = (*api.modes.GLOBALS, api.modes.MANIPULATE) 15 | MAX_ITEMS = 20 16 | MODE_HISTORY = { 17 | mode.name: [f":{mode.name}-{i:01d}" for i in range(MAX_ITEMS)] for mode in MODES 18 | } 19 | LEGACY_HISTORY = [f":command-{i:01d}" for i in range(MAX_ITEMS)] 20 | 21 | 22 | @pytest.fixture() 23 | def mode_based_history_file(tmp_path, mocker): 24 | """Fixture to create mode-based history file to initialize History.""" 25 | path = tmp_path / "history.json" 26 | 27 | with open(path, "w", encoding="utf-8") as f: 28 | json.dump(MODE_HISTORY, f) 29 | 30 | mocker.patch.object(History, "filename", return_value=str(path)) 31 | 32 | 33 | @pytest.fixture() 34 | def legacy_history_file(tmp_path, mocker): 35 | """Fixture to create legacy file to initialize History.""" 36 | path = tmp_path / "history" 37 | path.write_text("\n".join(LEGACY_HISTORY) + "\n") 38 | mocker.patch.object(History, "filename", return_value=str(path) + ".json") 39 | 40 | 41 | @pytest.fixture() 42 | def history(): 43 | """Fixture to create a clean history object to test.""" 44 | yield History(":", MAX_ITEMS) 45 | 46 | 47 | def test_read_history(mode_based_history_file, history): 48 | for mode, history_deque in history.items(): 49 | assert list(history_deque) == MODE_HISTORY[mode.name] 50 | 51 | 52 | def test_write_history(mode_based_history_file, history): 53 | for mode, history_deque in history.items(): 54 | history_deque.extend(MODE_HISTORY[mode.name]) 55 | history.write() 56 | 57 | with open(history.filename(), "r", encoding="utf-8") as f: 58 | written_history = json.load(f) 59 | 60 | assert written_history == MODE_HISTORY 61 | 62 | 63 | def test_migrate_history(legacy_history_file, history): 64 | # Correctly read into new mode - deque - structure 65 | assert [mode.name for mode in history] == list(MODE_HISTORY.keys()) 66 | for history_deque in history.values(): 67 | assert list(history_deque) == LEGACY_HISTORY 68 | # Backup created 69 | assert os.path.isfile(history.filename().replace(".json", ".bak")) 70 | -------------------------------------------------------------------------------- /tests/unit/commands/test_runners.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.commands.runners.""" 4 | 5 | import pytest 6 | 7 | from vimiv.commands import runners 8 | 9 | 10 | @pytest.mark.parametrize("text", [" ", "\n", " \n", "\t\t", "\n \t"]) 11 | def test_text_non_whitespace_with_whitespace(text): 12 | """Ensure the decorated function is not called with plain whitespace.""" 13 | 14 | @runners.text_non_whitespace 15 | def function(text): 16 | raise AssertionError("The function should not be called") 17 | 18 | function(text) 19 | 20 | 21 | @pytest.mark.parametrize("text", [" txt", "\ntxt", " \ntxt", "\ttxt\t", "\n txt\t"]) 22 | def test_text_non_whitespace_with_non_whitespace(text): 23 | """Ensure the decorated function is called with stripped text.""" 24 | 25 | @runners.text_non_whitespace 26 | def function(stripped_text): 27 | """Function to ensure any surrounding whitespace is removed.""" 28 | assert stripped_text == text.strip() 29 | 30 | function(text) 31 | -------------------------------------------------------------------------------- /tests/unit/commands/test_search.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.commands.search""" 4 | 5 | from vimiv.commands import search 6 | 7 | 8 | def test_clear_search(): 9 | search.search._text = "Something" 10 | search.search.clear() 11 | assert search.search._text == "" 12 | 13 | 14 | def test_sort_for_search(): 15 | testlist = [1, 2, 3] 16 | updated_list = search._sort_for_search(testlist, 1, False) 17 | assert updated_list == [2, 3, 1] 18 | 19 | 20 | def test_sort_for_search_reverse(): 21 | testlist = [1, 2, 3] 22 | updated_list = search._sort_for_search(testlist, 1, True) 23 | assert updated_list == [2, 1, 3] 24 | -------------------------------------------------------------------------------- /tests/unit/commands/test_wildcards.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.commands.wildcards.""" 4 | 5 | import shlex 6 | import string 7 | 8 | import pytest 9 | 10 | from vimiv.commands import wildcards 11 | 12 | 13 | @pytest.mark.parametrize("wildcard", ("%", "%m", "%wildcard", "%f")) 14 | @pytest.mark.parametrize("escaped", (True, False)) 15 | @pytest.mark.parametrize( 16 | "text", ("{wildcard} start", "in the {wildcard} middle", "end {wildcard}") 17 | ) 18 | def test_expand_wildcard(wildcard, escaped, text): 19 | text = text.format(wildcard=rf"\{wildcard}" if escaped else wildcard) 20 | paths = "this", "is", "text" 21 | result = wildcards.expand(text, wildcard, lambda: paths) 22 | 23 | if escaped: 24 | expected = text.replace("\\", "") 25 | else: 26 | expected = text.replace(wildcard, " ".join(paths)) 27 | 28 | assert result == expected 29 | 30 | 31 | @pytest.mark.parametrize("char", string.ascii_letters) 32 | @pytest.mark.parametrize("wildcard", ("%",)) 33 | def test_expand_with_backslash(wildcard, char): 34 | paths = (rf"\{char}",) 35 | expected = " ".join(shlex.quote(path) for path in paths) 36 | 37 | result = wildcards.expand(wildcard, wildcard, lambda: paths) 38 | assert result == expected 39 | 40 | 41 | def test_recursive_wildcards(): 42 | """Ensure unescaping of wildcards does not lead to them being matched later.""" 43 | text = r"This has an escaped wildcard \%m" 44 | expected = "This has an escaped wildcard %m" 45 | intermediate = wildcards.expand(text, "%m", lambda: "anything") 46 | result = wildcards.expand(intermediate, "%", lambda: "anything") 47 | assert result == expected 48 | 49 | 50 | @pytest.mark.parametrize("path", (r"\.jpg", "spaced path.jpg", r"\%.jpg")) 51 | def test_escape_path(path: str): 52 | expected = "'" + path.replace("\\", "\\\\").replace("%", "\\%") + "'" 53 | assert wildcards.escape_path(path) == expected 54 | -------------------------------------------------------------------------------- /tests/unit/config/test_config.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Unit tests for vimiv.config.""" 4 | 5 | import configparser 6 | 7 | import pytest 8 | 9 | from vimiv import config 10 | from vimiv.utils import customtypes 11 | 12 | 13 | @pytest.mark.parametrize( 14 | "content, message", 15 | [ 16 | ("GIBBERISH\n", "missing section header"), 17 | ("[SECTION]\nvalue", "key missing value"), 18 | ("[]\n", "empty section name"), 19 | ("[SECTION\n", "only opening section bracket"), 20 | ("SECTION]\n", "only closing section bracket"), 21 | ("[SECTION]\n[SECTION]\n", "duplicate section"), 22 | ("[SECTION]\na=0\na=1\n", "duplicate key"), 23 | ], 24 | ) 25 | def test_sysexit_on_broken_config(mocker, tmp_path, content, message): 26 | """Ensure SystemExit is correctly raised for various broken config files. 27 | 28 | Args: 29 | content: Content written to the configuration file which is invalid. 30 | message: Message printed to help debugging. 31 | """ 32 | print("Ensuring system exit with", message) 33 | mock_logger = mocker.Mock() 34 | parser = configparser.ConfigParser() 35 | path = tmp_path / "configfile" 36 | path.write_text(content) 37 | with pytest.raises(SystemExit, match=str(customtypes.Exit.err_config)): 38 | config.read_log_exception(parser, mock_logger, str(path)) 39 | mock_logger.critical.assert_called_once() 40 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Fixtures for pytest unit tests.""" 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture() 9 | def tmpfile(tmp_path): 10 | path = tmp_path / "anything" 11 | path.touch() 12 | yield str(path) 13 | -------------------------------------------------------------------------------- /tests/unit/gui/test_statusbar.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.gui.statusbar.""" 4 | 5 | import pytest 6 | 7 | from vimiv.gui import statusbar 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "text, expected", 12 | [ 13 | (" ", " "), 14 | ("this is text", "this is text"), 15 | ("one two", "one  two"), 16 | ("one two three four five", "one two  three  four five"), 17 | ], 18 | ) 19 | def test_escape_subsequent_space_for_html(text, expected): 20 | assert statusbar.StatusBar._escape_subsequent_space_for_html(text) == expected 21 | -------------------------------------------------------------------------------- /tests/unit/gui/test_thumbnail.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.gui.thumbnail.""" 4 | 5 | import pytest 6 | 7 | from vimiv.qt.core import QSize 8 | from vimiv.qt.gui import QIcon 9 | 10 | from vimiv.gui.thumbnail import ThumbnailItem 11 | 12 | 13 | @pytest.fixture() 14 | def item(mocker): 15 | """Fixture to retrieve a vanilla ThumbnailItem.""" 16 | ThumbnailItem._default_icon = None 17 | mocker.patch.object(ThumbnailItem, "create_default_icon", return_value=QIcon()) 18 | yield ThumbnailItem 19 | 20 | 21 | def test_create_default_pixmap_once(item): 22 | """Ensure the default thumbnail icon is only created once.""" 23 | size_hint = QSize(128, 128) 24 | for index in range(5): 25 | item(None, index, size_hint=size_hint) 26 | item.create_default_icon.assert_called_once() 27 | -------------------------------------------------------------------------------- /tests/unit/imutils/test_imtransform.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.imutils.imtransform.""" 4 | 5 | import functools 6 | 7 | import pytest 8 | 9 | from vimiv.qt.gui import QPixmap 10 | 11 | from vimiv.imutils import current_pixmap, imtransform 12 | 13 | 14 | ACTIONS = ( 15 | imtransform.Transform.rotate_command, 16 | imtransform.Transform.flip, 17 | functools.partial(imtransform.Transform.resize, width=400, height=400), 18 | functools.partial(imtransform.Transform.rescale, dx=2, dy=2), 19 | ) 20 | 21 | 22 | @pytest.fixture(scope="function") 23 | def transform(qtbot, mocker): 24 | """Fixture to retrieve a clean Transform instance.""" 25 | pixmap = QPixmap(300, 300) 26 | current_pm = current_pixmap.CurrentPixmap() 27 | transform = imtransform.Transform(current_pm) 28 | current_pm.pixmap = transform.original = pixmap 29 | return transform 30 | 31 | 32 | @pytest.fixture(params=ACTIONS) 33 | def action(transform, request): 34 | action = request.param 35 | return functools.partial(action, self=transform) 36 | 37 | 38 | def test_change_and_reset(action, transform): 39 | """Ensure every action leads to a change and is appropriately reset.""" 40 | action() 41 | assert transform.changed 42 | transform.reset() 43 | assert not transform.changed 44 | 45 | 46 | @pytest.mark.parametrize("angle", range(0, 360, 15)) 47 | def test_rotate_angle(transform, angle): 48 | transform.rotate(angle) 49 | assert transform.angle == pytest.approx(angle) 50 | -------------------------------------------------------------------------------- /tests/unit/plugins/mock_plugin.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Mock plugin to test loading a plugin.""" 4 | 5 | 6 | def init(_info: str, *args, **kwargs): 7 | pass 8 | 9 | 10 | def cleanup(*args, **kwargs): 11 | pass 12 | -------------------------------------------------------------------------------- /tests/unit/plugins/mock_plugin_syntax_error.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Mock plugin to test loading a plugin with a syntax error.""" 4 | 5 | for 6 | -------------------------------------------------------------------------------- /tests/unit/plugins/test_plugins.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.plugins.""" 4 | 5 | import os 6 | 7 | import pytest 8 | 9 | from vimiv import plugins 10 | 11 | 12 | @pytest.fixture 13 | def mock_plugin(mocker): 14 | name = "mock_plugin" 15 | info = "useful" 16 | mocker.patch("mock_plugin.init") 17 | mocker.patch("mock_plugin.cleanup") 18 | plugins._load_plugin(name, info, os.path.abspath(".")) 19 | plugins.cleanup() 20 | yield plugins._loaded_plugins[name], info 21 | del plugins._loaded_plugins[name] 22 | 23 | 24 | def test_init_and_cleanup_plugin(mock_plugin): 25 | plugin, info = mock_plugin 26 | plugin.init.assert_called_once_with(info) 27 | plugin.cleanup.assert_called_once() 28 | 29 | 30 | def test_do_not_fail_on_non_existing_plugin(): 31 | plugins._load_plugin("does_not_exist", "any info", os.path.abspath(".")) 32 | 33 | 34 | def test_do_not_fail_on_plugin_with_syntax_error(): 35 | name = "mock_plugin_syntax_error" 36 | info = "useful" 37 | plugins._load_plugin(name, info, os.path.abspath(".")) 38 | -------------------------------------------------------------------------------- /tests/unit/test_checkversion.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Unit tests for vimiv.checkversion.""" 4 | 5 | import sys 6 | 7 | import pytest 8 | 9 | from vimiv import checkversion 10 | import vimiv.qt.core 11 | 12 | 13 | @pytest.mark.parametrize("version_info", ((1,), (2, 6), (3, 5, 900))) 14 | def test_check_python_version(capsys, monkeypatch, version_info): 15 | """Tests to ensure exit and error message on too low python version.""" 16 | monkeypatch.setattr(sys, "version_info", version_info) 17 | 18 | with pytest.raises(SystemExit, match=str(checkversion.ERR_CODE)): 19 | checkversion.check_python_version() 20 | 21 | expected = build_message( 22 | "python", checkversion.PYTHON_REQUIRED_VERSION, version_info 23 | ) 24 | captured = capsys.readouterr() 25 | assert captured.err == expected 26 | 27 | 28 | @pytest.mark.parametrize("version_info", ((1,), (4, 6), (5, 8, 900))) 29 | def test_check_pyqt_version(capsys, monkeypatch, version_info): 30 | """Tests to ensure exit and error message on too low PyQt version.""" 31 | monkeypatch.setattr( 32 | vimiv.qt.core, "PYQT_VERSION_STR", ".".join(str(i) for i in version_info) 33 | ) 34 | 35 | with pytest.raises(SystemExit, match=str(checkversion.ERR_CODE)): 36 | checkversion.check_pyqt_version() 37 | 38 | expected = build_message("PyQt", checkversion.PYQT_REQUIRED_VERSION, version_info) 39 | captured = capsys.readouterr() 40 | assert captured.err == expected 41 | 42 | 43 | def build_message(software, required, version): 44 | """Helper to create the expected error message on too low software version.""" 45 | # pylint: disable=consider-using-f-string 46 | return "At least %s %s is required to run vimiv. Using %s.\n" % ( 47 | software, 48 | ".".join(str(i) for i in required), 49 | ".".join(str(i) for i in version), 50 | ) 51 | -------------------------------------------------------------------------------- /tests/unit/test_version.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.version.""" 4 | 5 | import pytest 6 | 7 | from vimiv import version 8 | 9 | 10 | @pytest.fixture 11 | def no_svg_support(monkeypatch): 12 | monkeypatch.setattr(version, "QtSvg", None) 13 | 14 | 15 | def test_svg_support_info(): 16 | assert "svg support: true" in version.info().lower() 17 | 18 | 19 | def test_no_svg_support_info(no_svg_support): 20 | assert "svg support: false" in version.info().lower() 21 | -------------------------------------------------------------------------------- /tests/unit/utils/_module_for_lazy.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Dummy module used for testing the lazy import mechanism.""" 4 | 5 | print(__name__) # Side effect we can check for 6 | 7 | RETURN_VALUE = 42 8 | 9 | 10 | def function_of_interest(): 11 | return RETURN_VALUE 12 | -------------------------------------------------------------------------------- /tests/unit/utils/test_debug.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.utils.debug""" 4 | 5 | import re 6 | 7 | from vimiv.utils import debug 8 | 9 | 10 | def test_profiler(capsys): 11 | with debug.profile(5): 12 | pass 13 | assert "function calls" in capsys.readouterr().out 14 | 15 | 16 | def test_timed(mocker, capsys): 17 | mocker.patch("vimiv.utils.log.info") 18 | expected = 42 19 | 20 | @debug.timed 21 | def func(): 22 | return expected 23 | 24 | result = func() 25 | 26 | assert result == expected # Ensure the result is preserved 27 | captured = capsys.readouterr() 28 | assert func.__name__ in captured.out # Ensure a message was printed 29 | # Ensure the message contains the elapsed time 30 | time_match = re.search(r"\d+.\d+", captured.out) 31 | assert time_match is not None, "No time logged" 32 | -------------------------------------------------------------------------------- /tests/unit/utils/test_lazy.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.utils.lazy.""" 4 | 5 | import functools 6 | import sys 7 | 8 | import pytest 9 | 10 | from vimiv.utils import lazy 11 | 12 | MODULE_NAME = "_module_for_lazy" 13 | 14 | 15 | @pytest.fixture(autouse=True) 16 | def clear_imported_module(): 17 | """Fixture to clean the module lru_cache and the imported module.""" 18 | yield 19 | lazy.Module.factory.cache_clear() 20 | if MODULE_NAME in sys.modules: 21 | del sys.modules[MODULE_NAME] 22 | 23 | 24 | @pytest.fixture() 25 | def module(): 26 | """Fixture to return a lazy imported module.""" 27 | yield lazy.import_module(MODULE_NAME) 28 | 29 | 30 | def test_lazy_import_is_lazy(module, capsys): 31 | """Ensure the module is not executed upon import.""" 32 | captured = capsys.readouterr() 33 | assert MODULE_NAME not in captured.out 34 | 35 | 36 | def test_lazy_imported_module(module, capsys): 37 | """Ensure the module works as expected when used.""" 38 | assert module.function_of_interest() == module.RETURN_VALUE 39 | captured = capsys.readouterr() 40 | assert MODULE_NAME in captured.out 41 | 42 | 43 | def test_lazy_module_single_instance(module): 44 | """Ensure we only create a single instance per module name.""" 45 | module_second_import = lazy.import_module(MODULE_NAME) 46 | assert module_second_import is module 47 | 48 | 49 | @pytest.mark.parametrize("name", ("not_a_valid_module", "not_a_valid_module.submodule")) 50 | @pytest.mark.parametrize("optional", (True, False)) 51 | def test_lazy_import_nonexisting_module(name, optional): 52 | importfunc = functools.partial(lazy.import_module, name, optional=optional) 53 | if optional: 54 | assert importfunc() is None 55 | else: 56 | with pytest.raises(ModuleNotFoundError, match=name): 57 | importfunc() 58 | -------------------------------------------------------------------------------- /tests/unit/utils/test_xdg.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Tests for vimiv.utils.xdg.""" 4 | 5 | import os 6 | 7 | import pytest 8 | 9 | from vimiv.utils import xdg 10 | 11 | 12 | @pytest.fixture 13 | def unset_xdg_env(monkeypatch): 14 | """Unset the XDG_*_HOME environment variables.""" 15 | monkeypatch.delenv("XDG_CACHE_HOME", raising=False) 16 | monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) 17 | monkeypatch.delenv("XDG_DATA_HOME", raising=False) 18 | 19 | 20 | @pytest.fixture 21 | def mock_xdg(tmp_path, monkeypatch): 22 | """Set XDG_* directories to a temporary directory.""" 23 | dirname = str(tmp_path / "directory") 24 | monkeypatch.setenv("XDG_CACHE_HOME", dirname) 25 | monkeypatch.setenv("XDG_CONFIG_HOME", dirname) 26 | monkeypatch.setenv("XDG_DATA_HOME", dirname) 27 | yield dirname 28 | 29 | 30 | def test_xdg_defaults(unset_xdg_env): 31 | expected = { 32 | xdg.user_cache_dir: "~/.cache", 33 | xdg.user_config_dir: "~/.config", 34 | xdg.user_data_dir: "~/.local/share", 35 | } 36 | for func, expected_retval in expected.items(): 37 | assert func() == os.path.expanduser(expected_retval) 38 | 39 | 40 | def test_xdg_from_env(mock_xdg): 41 | for func in [xdg.user_cache_dir, xdg.user_config_dir, xdg.user_data_dir]: 42 | assert func() == mock_xdg 43 | 44 | 45 | @pytest.mark.parametrize("paths", [tuple(), ("path",), ("directory", "path")]) 46 | def test_vimiv_xdg_dirs(mock_xdg, paths): 47 | for func in [xdg.vimiv_cache_dir, xdg.vimiv_config_dir, xdg.vimiv_data_dir]: 48 | assert func(*paths) == os.path.join(mock_xdg, "vimiv", *paths) 49 | -------------------------------------------------------------------------------- /vimiv/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """An image viewer with vim-like keybindings based on PyQt5.""" 4 | 5 | import vimiv.checkversion 6 | 7 | __license__ = "GPL3" 8 | __version_info__ = (0, 9, 0) 9 | __version__ = ".".join(str(num) for num in __version_info__) 10 | __author__ = "Christian Karl" 11 | __maintainer__ = __author__ 12 | __email__ = "karlch@protonmail.com" 13 | __description__ = "An image viewer with vim-like keybindings." 14 | __url__ = "https://karlch.github.io/vimiv-qt/" 15 | -------------------------------------------------------------------------------- /vimiv/__main__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Entry point for vimiv. Run the vimiv process.""" 4 | 5 | import sys 6 | 7 | import vimiv.startup 8 | 9 | 10 | if __name__ == "__main__": 11 | sys.exit(vimiv.startup.main()) 12 | -------------------------------------------------------------------------------- /vimiv/api/prompt.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Prompt the user for a question.""" 4 | 5 | import typing 6 | 7 | from vimiv.qt.core import QObject, Signal 8 | 9 | from vimiv.utils import log 10 | 11 | 12 | _logger = log.module_logger(__name__) 13 | 14 | 15 | class Question: 16 | """Storage class for a question to the user. 17 | 18 | Attributes: 19 | title: Title of the question. 20 | body: Sentence body representing the actual question. 21 | answer: Answer given by the user if any. 22 | """ 23 | 24 | def __init__(self, *, title: str, body: str): 25 | self.title = title 26 | self.body = body 27 | self.answer = None 28 | 29 | def __repr__(self) -> str: 30 | return f"{self.__class__.__qualname__}(title={self.title}, body={self.body})" 31 | 32 | 33 | class _Bridge(QObject): 34 | """Message bridge using the signal-slot infrastructure. 35 | 36 | Signals: 37 | question_asked: Emitted when a question to the user was asked. 38 | arg1: The Question instance storing all information on the question. 39 | """ 40 | 41 | question_asked = Signal(Question) 42 | 43 | 44 | def ask_question(*, title: str, body: str) -> typing.Any: 45 | """Prompt the user to answer a question. 46 | 47 | This emits the question_asked signal which leads to a gui prompt being displayed. 48 | The UI is blocked until the question was answered or aborted. 49 | 50 | Args: 51 | title: Title of the question. 52 | body: Sentence body representing the actual question. 53 | Returns: 54 | answer: Answer given by the user if any. 55 | """ 56 | question = Question(title=title, body=body) 57 | _logger.debug("Asking question '%s'", question.title) 58 | question_asked.emit(question) # Enters a gui prompt widget 59 | _logger.debug("Answered '%s' with '%s'", question.title, question.answer) 60 | return question.answer 61 | 62 | 63 | # Expose only signals from the bridge 64 | _bridge = _Bridge() 65 | question_asked = _bridge.question_asked 66 | -------------------------------------------------------------------------------- /vimiv/checkversion.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Check version and availability of required software upon import. 4 | 5 | This module is imported first in the top-level __init__.py to ensure we are running with 6 | the correct python and PyQt versions. In case this fails, error messages are written to 7 | stderr and we exit with returncode ERR_CODE. 8 | 9 | Module Attributes: 10 | PYQT_VERSION: The currently installed PyQt version as tuple if any. 11 | PYQT_REQUIRED_VERSION: The minimum PyQt version required. 12 | PYTHON_REQUIRED_VERSION: The minimum python version required. 13 | ERR_CODE: Returncode used with sys.exit. 14 | """ 15 | 16 | import sys 17 | 18 | 19 | PYTHON_REQUIRED_VERSION = (3, 8) 20 | PYQT_REQUIRED_VERSION = (5, 13, 2) 21 | ERR_CODE = 2 22 | 23 | 24 | def check_python_version(): 25 | """Ensure the python version is new enough.""" 26 | if sys.version_info < PYTHON_REQUIRED_VERSION: 27 | _exit_version("python", PYTHON_REQUIRED_VERSION, sys.version_info[:3]) 28 | 29 | 30 | def check_pyqt_version(): 31 | """Ensure the PyQt version is new enough.""" 32 | try: 33 | from vimiv.qt.core import PYQT_VERSION_STR 34 | except ImportError as e: # TODO add back test for this 35 | _exit( 36 | f"Error importing a valid Qt python wrapper:\n{e}\n\n" 37 | "Please install / configure a valid wrapper to run vimiv.\n" 38 | ) 39 | 40 | pyqt_version = tuple(map(int, PYQT_VERSION_STR.split(".")[:3])) 41 | if pyqt_version < PYQT_REQUIRED_VERSION: 42 | _exit_version("PyQt", PYQT_REQUIRED_VERSION, pyqt_version) 43 | 44 | 45 | def join_version_tuple(version): 46 | return ".".join(map(str, version)) 47 | 48 | 49 | def _exit_version(software, required, installed): 50 | """Call exit for out-of-date software.""" 51 | # This module needs to work for python < 3.6 52 | # pylint: disable=consider-using-f-string 53 | _exit( 54 | "At least %s %s is required to run vimiv. Using %s.\n" 55 | % (software, join_version_tuple(required), join_version_tuple(installed)) 56 | ) 57 | 58 | 59 | def _exit(message): 60 | """Write message to stderr and exit with returncode 1.""" 61 | sys.stderr.write(message) 62 | sys.stderr.flush() 63 | sys.exit(ERR_CODE) 64 | 65 | 66 | check_python_version() 67 | check_pyqt_version() 68 | -------------------------------------------------------------------------------- /vimiv/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Functions to store and run commands.""" 4 | 5 | from typing import cast 6 | 7 | 8 | def number_for_command( 9 | number: int = None, 10 | count: int = None, 11 | *, 12 | max_count: int, 13 | number_name: str = "index", 14 | elem_name: str = "element", 15 | ) -> int: 16 | """Return correct number for command given number, optional count and a maximum. 17 | 18 | Count is preferred over the number if it is given. The command expects numbers 19 | indexed from one but returns a number indexed from zero. If the number exceeds the 20 | maximum, the modulo operator is used to reduce it accordingly. 21 | """ 22 | if number is None and count is None: 23 | raise ValueError(f"Either {number_name} or count is required") 24 | if max_count <= 0: 25 | raise ValueError(f"No {elem_name} in list") 26 | if count is not None: 27 | number = count 28 | number = cast(int, number) # Ensured by the two tests above 29 | if number > 0: 30 | number -= 1 31 | return number % max_count 32 | -------------------------------------------------------------------------------- /vimiv/commands/aliases.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Class and functions to add and get aliases. 4 | 5 | Module Attribute: 6 | _aliases: Dictionary storing aliases initialized with defaults. 7 | """ 8 | 9 | from typing import Dict, List 10 | 11 | from vimiv import api 12 | 13 | Aliases = Dict[str, str] 14 | 15 | 16 | _aliases: Dict[api.modes.Mode, Aliases] = {mode: {} for mode in api.modes.ALL} 17 | # Add default aliases 18 | _aliases[api.modes.GLOBAL].update({"q": "quit", "mark-print": "print-stdout %m"}) 19 | _aliases[api.modes.IMAGE].update(w="write", wq="write && quit") 20 | 21 | 22 | def get(mode: api.modes.Mode) -> Aliases: 23 | """Return all aliases for the mode 'mode'.""" 24 | if mode in api.modes.GLOBALS: 25 | return {**_aliases[api.modes.GLOBAL], **_aliases[mode]} 26 | return _aliases[mode] 27 | 28 | 29 | @api.commands.register() 30 | def alias(name: str, command: List[str], mode: str = "global"): 31 | """Add an alias for a command. 32 | 33 | **syntax:** ``:alias name command [--mode=MODE]`` 34 | 35 | The command can be a vimiv command like ``quit`` or an external shell 36 | command like ``!gimp``. 37 | 38 | positional arguments: 39 | * ``name``: Name of the newly defined alias. 40 | * ``command``: Name of the command to alias. 41 | 42 | optional arguments: 43 | * ``--mode``: Mode in which the alias is valid. Default: ``global``. 44 | """ 45 | assert isinstance(command, list), "Aliases defined as list via nargs='*'" 46 | assert isinstance(mode, str), "Mode must be passed to alias command as string" 47 | commandstr = " ".join(command) 48 | modeobj = api.modes.get_by_name(mode) 49 | if api.commands.exists(name, modeobj): 50 | raise api.commands.CommandError(f"Not overriding default command {name}") 51 | _aliases[modeobj][name] = commandstr 52 | -------------------------------------------------------------------------------- /vimiv/commands/misccommands.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Miscellaneous commands that don't really fit anywhere.""" 4 | 5 | import logging 6 | import time 7 | from typing import List 8 | 9 | from vimiv import api, utils 10 | 11 | 12 | _logger = utils.log.module_logger(__name__) 13 | 14 | 15 | @api.commands.register() 16 | def log(level: str, message: List[str]): 17 | """Log a message with the corresponding log level. 18 | 19 | **syntax:** ``:log level message`` 20 | 21 | positional arguments: 22 | * ``level``: Log level of the message (debug, info, warning, error, critical). 23 | * ``message``: Message to log. 24 | """ 25 | try: 26 | log_level = getattr(logging, level.upper()) 27 | except AttributeError: 28 | raise api.commands.CommandError(f"Unknown log level '{level}'") 29 | utils.log.log(log_level, " ".join(message)) 30 | 31 | 32 | @api.commands.register() 33 | def sleep(duration: float): 34 | """Sleep for a given number of seconds. 35 | 36 | **syntax:** ``:sleep duration`` 37 | 38 | positional arguments: 39 | * ``duration``: The number of seconds to sleep. 40 | """ 41 | _logger.debug("Sleeping for %.2f seconds, good-night :)", duration) 42 | time.sleep(duration) 43 | _logger.debug("Woke up nice and refreshed!") 44 | -------------------------------------------------------------------------------- /vimiv/completion/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Modules for command line completion.""" 4 | -------------------------------------------------------------------------------- /vimiv/gui/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """GUI widgets using Qt5.""" 4 | -------------------------------------------------------------------------------- /vimiv/gui/synchronize.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Helper module to synchronize selection of library and thumbnail mode. 4 | 5 | Module Attributes: 6 | signals: Signals used as synchronization method. 7 | """ 8 | 9 | 10 | from vimiv.qt.core import Signal, QObject 11 | 12 | 13 | class _Signals(QObject): 14 | new_library_path_selected = Signal(str) 15 | new_thumbnail_path_selected = Signal(str) 16 | 17 | 18 | signals = _Signals() 19 | -------------------------------------------------------------------------------- /vimiv/gui/version_popup.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Pop-up window to display version information.""" 4 | 5 | from vimiv.qt.widgets import QLabel, QVBoxLayout, QPushButton 6 | from vimiv.qt.gui import QGuiApplication 7 | 8 | import vimiv.version 9 | from vimiv.widgets import PopUp 10 | 11 | 12 | class VersionPopUp(PopUp): 13 | """Pop up that displays version information on initialization. 14 | 15 | Class Attributes: 16 | TITLE: Window title used for the pop up. 17 | """ 18 | 19 | TITLE = f"{vimiv.__name__} - version" 20 | 21 | def __init__(self, parent=None): 22 | super().__init__(self.TITLE, parent=parent) 23 | self._init_content() 24 | self.show() 25 | 26 | def _init_content(self): 27 | """Initialize all widgets of the pop-up window.""" 28 | layout = QVBoxLayout() 29 | layout.addWidget(QLabel(vimiv.version.detailed_info())) 30 | layout.addWidget( 31 | QLabel("Website: {url}".format(url=vimiv.__url__)) 32 | ) 33 | button = QPushButton("&Copy version info to clipboard") 34 | button.clicked.connect(self.copy_to_clipboard) 35 | button.setFlat(True) 36 | layout.addWidget(button) 37 | self.setLayout(layout) 38 | 39 | @staticmethod 40 | def copy_to_clipboard() -> None: 41 | """Copy version information to clipboard.""" 42 | QGuiApplication.clipboard().setText(vimiv.version.info()) 43 | -------------------------------------------------------------------------------- /vimiv/imutils/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """`Utilities to load, edit, navigate and save images`. 4 | 5 | The Image Loading Process 6 | ,,,,,,,,,,,,,,,,,,,,,,,,, 7 | 8 | The image loading process is started by emitting the ``load_images`` signal. It is for 9 | example emitted by the library when a new image path was selected, in thumbnail mode 10 | upon selection or by the ``:open`` command. There are a few different cases that are 11 | taken care of: 12 | 13 | * Loading a single path that is already in the filelist 14 | In this case the filelist navigates to the appropriate index and the image is 15 | opened. 16 | * Loading a single path that is not in the filelist 17 | The filelist is populated with all images in the same directory as this path and 18 | the path is opened. 19 | * Loading multiple paths 20 | The filelist is populated with these paths and the first file in the list is 21 | opened. 22 | 23 | To open an image the ``new_image_opened`` signal is emitted with the absolute path to 24 | this image. This signal is accepted by the file handler in 25 | ``vimiv.imutils._file_handler`` which then loads the actual image from disk using 26 | ``QImageReader``. Once the format of the image has been determined, and a displayable Qt 27 | widget has been created, the file handler emits one of: 28 | 29 | * ``pixmap_loaded`` for standard images 30 | * ``movie_loaded`` for animated Gifs 31 | * ``svg_loaded`` for vector graphics 32 | 33 | The image widget in ``vimiv.gui.image`` connects to these signals and displays 34 | the appropriate Qt widget. 35 | """ 36 | 37 | from vimiv.imutils import metadata 38 | from vimiv.imutils.edit_handler import EditHandler 39 | from vimiv.imutils.filelist import current, pathlist 40 | from vimiv.imutils.filelist import SignalHandler as _FilelistSignalHandler 41 | from vimiv.imutils._file_handler import ImageFileHandler as _ImageFileHandler 42 | 43 | 44 | def init(): 45 | """Initialize the classes needed for imutils.""" 46 | _FilelistSignalHandler() 47 | _ImageFileHandler() 48 | -------------------------------------------------------------------------------- /vimiv/imutils/current_pixmap.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Storage class for the current pixmap.""" 4 | 5 | from vimiv.qt.gui import QPixmap 6 | 7 | 8 | class CurrentPixmap: 9 | """Storage class for the current pixmap shared between various edit-related classes. 10 | 11 | We do not use a simple QPixmap as we would have to update various attributes of the 12 | classes that wish to access the pixmap simultaneously. Like this they can all share 13 | this class and access the pixmap through it. 14 | 15 | Attributes: 16 | pixmap: The current, possibly edited, pixmap. 17 | """ 18 | 19 | def __init__(self): 20 | self.pixmap = QPixmap() 21 | 22 | @property 23 | def editable(self) -> bool: 24 | """True if the currently opened image is transformable/manipulatable.""" 25 | return not self.pixmap.isNull() 26 | -------------------------------------------------------------------------------- /vimiv/imutils/slideshow.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Module to play a slideshow.""" 4 | 5 | from typing import Optional 6 | 7 | from vimiv.qt.core import QTimer 8 | 9 | from vimiv import api 10 | 11 | 12 | class Timer(QTimer): 13 | """Slideshow timer with interval according to the current setting.""" 14 | 15 | def __init__(self): 16 | super().__init__() 17 | api.settings.slideshow.delay.changed.connect(self._set_delay) 18 | self._set_delay(api.settings.slideshow.delay.value) 19 | 20 | def _set_delay(self, value: float): 21 | self.setInterval(int(value * 1000)) 22 | 23 | 24 | # Create timer and expose a few methods to the public 25 | _timer = Timer() 26 | event = _timer.timeout 27 | stop = _timer.stop 28 | start = _timer.start 29 | 30 | 31 | @api.keybindings.register("ss", "slideshow", mode=api.modes.IMAGE) 32 | @api.commands.register(mode=api.modes.IMAGE, name="slideshow") 33 | def toggle(count: Optional[int] = None): 34 | """Toggle slideshow. 35 | 36 | **count:** Set slideshow delay to count instead. 37 | """ 38 | if count is not None: 39 | api.settings.slideshow.delay.value = count 40 | _timer.setInterval(1000 * count) 41 | elif _timer.isActive(): 42 | _timer.stop() 43 | else: 44 | _timer.start() 45 | 46 | 47 | @api.status.module("{slideshow-delay}") 48 | def delay() -> str: 49 | """Slideshow delay in seconds if the slideshow is running.""" 50 | if _timer.isActive(): 51 | interval_seconds = _timer.interval() / 1000 52 | return f"{interval_seconds:.1f}" 53 | return "" 54 | 55 | 56 | @api.status.module("{slideshow-indicator}") 57 | def running_indicator() -> str: 58 | """Indicator if slideshow is running.""" 59 | return api.settings.slideshow.indicator.value if _timer.isActive() else "" 60 | -------------------------------------------------------------------------------- /vimiv/plugins/demo.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Simple hello world greeting as example plugin. 4 | 5 | The plugin defines a new command :hello-world which simply prints a message to the 6 | terminal. The init and cleanup functions also print messages to the terminal without 7 | further usage for demonstration purposes. 8 | """ 9 | 10 | from typing import Any 11 | 12 | from vimiv import api 13 | 14 | 15 | @api.commands.register() 16 | def hello_world() -> None: 17 | """Simple dummy function printing 'Hello world'.""" 18 | print("Hello world") 19 | 20 | 21 | def init(info: str, *_args: Any, **_kwargs: Any) -> None: 22 | print(f"Initializing demo plugin with '{info}'") 23 | 24 | 25 | def cleanup(*_args: Any, **_kwargs: Any) -> None: 26 | print("Cleaning up demo plugin") 27 | -------------------------------------------------------------------------------- /vimiv/plugins/metadata.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Metadata plugin wrapping the available backends to only load one.""" 4 | 5 | from typing import Any 6 | 7 | from vimiv.plugins import metadata_piexif, metadata_pyexiv2 8 | from vimiv.utils import log 9 | from vimiv.imutils import metadata 10 | 11 | _logger = log.module_logger(__name__) 12 | 13 | 14 | def init(info: str, *_args: Any, **_kwargs: Any) -> None: 15 | """Initialize metadata plugin depending on available backend. 16 | 17 | If any other backend has already been registered, do not register any new one. 18 | """ 19 | if metadata.has_metadata_support(): 20 | _logger.debug( 21 | "Not loading a default metadata backend, as one has been loaded manually" 22 | ) 23 | elif info.lower() == "none": 24 | _logger.debug("Not auto-loading metadata support as per user-request") 25 | elif metadata_pyexiv2.pyexiv2 is not None: 26 | _logger.debug("Auto-loading pyexiv2 metadata plugin") 27 | metadata_pyexiv2.init() 28 | elif metadata_piexif.piexif is not None: 29 | _logger.debug("Auto-loading piexif metadata plugin") 30 | metadata_piexif.init() 31 | else: 32 | _logger.warning( 33 | "Please install either py3exiv2 or piexif for metadata support.
\n" 34 | "For more information see
\n" 35 | "https://karlch.github.io/vimiv-qt/documentation/metadata.html", 36 | ) 37 | -------------------------------------------------------------------------------- /vimiv/qt/core.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | # pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import 4 | 5 | from vimiv import qt 6 | 7 | 8 | if qt.USE_PYQT5: 9 | from PyQt5.QtCore import * 10 | elif qt.USE_PYQT6: 11 | from PyQt6.QtCore import * 12 | elif qt.USE_PYSIDE6: 13 | # TODO remove useless-suppression once we add PySide6 back to pylint toxenv 14 | # pylint: disable=no-name-in-module,undefined-variable,useless-suppression 15 | from PySide6.QtCore import * 16 | from PySide6.QtCore import __version__ as PYQT_VERSION_STR 17 | 18 | BoundSignal = SignalInstance 19 | QT_VERSION_STR = qVersion() 20 | 21 | if qt.USE_PYQT: # Signal aliases 22 | # pylint: disable=used-before-assignment 23 | BoundSignal = pyqtBoundSignal 24 | Signal = pyqtSignal 25 | Slot = pyqtSlot 26 | 27 | 28 | class Align: 29 | """Namespace for easier access to the Qt alignment flags.""" 30 | 31 | # pylint: disable=used-before-assignment 32 | Center = Qt.AlignmentFlag.AlignCenter 33 | Left = Qt.AlignmentFlag.AlignLeft 34 | Right = Qt.AlignmentFlag.AlignRight 35 | Top = Qt.AlignmentFlag.AlignTop 36 | Bottom = Qt.AlignmentFlag.AlignBottom 37 | -------------------------------------------------------------------------------- /vimiv/qt/gui.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | # pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import 4 | 5 | from vimiv import qt 6 | 7 | 8 | if qt.USE_PYQT5: 9 | from PyQt5.QtGui import * 10 | elif qt.USE_PYQT6: 11 | from PyQt6.QtGui import * 12 | elif qt.USE_PYSIDE6: 13 | from PySide6.QtGui import * 14 | -------------------------------------------------------------------------------- /vimiv/qt/printsupport.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | # pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import 4 | 5 | from vimiv import qt 6 | from vimiv.qt import gui 7 | 8 | 9 | if qt.USE_PYQT5: 10 | from PyQt5.QtPrintSupport import * 11 | 12 | Orientation = QPrinter.Orientation 13 | elif qt.USE_PYQT6: 14 | from PyQt6.QtPrintSupport import * 15 | 16 | Orientation = gui.QPageLayout.Orientation 17 | elif qt.USE_PYSIDE6: 18 | from PySide6.QtPrintSupport import * 19 | 20 | Orientation = gui.QPageLayout.Orientation 21 | -------------------------------------------------------------------------------- /vimiv/qt/svg.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | # pylint: disable=missing-module-docstring 4 | 5 | from vimiv import qt 6 | from vimiv.utils import lazy 7 | 8 | # Lazy import the module that implements QSvgWidget 9 | if qt.USE_PYQT5: 10 | QtSvg = lazy.import_module("PyQt5.QtSvg", optional=True) 11 | elif qt.USE_PYQT6: 12 | QtSvg = lazy.import_module("PyQt6.QtSvgWidgets", optional=True) 13 | elif qt.USE_PYSIDE6: 14 | QtSvg = lazy.import_module("PySide6.QtSvgWidgets", optional=True) 15 | -------------------------------------------------------------------------------- /vimiv/qt/widgets.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | # pylint: disable=missing-module-docstring,wildcard-import,unused-wildcard-import 4 | 5 | from vimiv import qt 6 | 7 | 8 | if qt.USE_PYQT5: 9 | from PyQt5.QtWidgets import * 10 | if qt.USE_PYQT6: 11 | from PyQt6.QtWidgets import * 12 | elif qt.USE_PYSIDE6: 13 | from PySide6.QtWidgets import * 14 | -------------------------------------------------------------------------------- /vimiv/utils/customtypes.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Custom data types.""" 4 | 5 | import enum 6 | import typing 7 | 8 | from vimiv import checkversion 9 | 10 | 11 | AnyT = typing.TypeVar("AnyT") 12 | FuncT = typing.TypeVar("FuncT", bound=typing.Callable[..., typing.Any]) 13 | FuncNoneT = typing.TypeVar("FuncNoneT", bound=typing.Callable[..., None]) 14 | NumberT = typing.TypeVar("NumberT", int, float) 15 | 16 | Number = typing.Union[int, float] 17 | NumberStr = typing.Union[Number, str] 18 | IntStr = typing.Union[int, str] 19 | 20 | 21 | @enum.unique 22 | class Exit(enum.IntEnum): 23 | """Enum class for the different integer exit codes.""" 24 | 25 | success = 0 26 | err_exception = 1 # Uncaught exception 27 | err_version = checkversion.ERR_CODE # Unsupported dependency version 28 | err_config = 3 # Critical error when parsing configuration files 29 | err_suicide = 42 # Forceful quit 30 | signal = 128 # Exit by signal + signum 31 | -------------------------------------------------------------------------------- /vimiv/utils/debug.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Various utility functions for debugging and profiling.""" 4 | 5 | import contextlib 6 | import cProfile 7 | import functools 8 | import pstats 9 | import time 10 | from typing import Any, Iterator 11 | 12 | from vimiv.utils.customtypes import FuncT 13 | 14 | 15 | def timed(function: FuncT) -> FuncT: 16 | """Decorator to time a function and log evaluation time.""" 17 | 18 | @functools.wraps(function) 19 | def inner(*args: Any, **kwargs: Any) -> Any: 20 | """Wrap decorated function and add timing.""" 21 | start = time.time() 22 | return_value = function(*args, **kwargs) 23 | elapsed_in_ms = (time.time() - start) * 1000 24 | print(f"{function.__qualname__}: took {elapsed_in_ms:.3f} ms") 25 | return return_value 26 | 27 | # Mypy seems to disapprove the *args, **kwargs, but we just wrap the function 28 | return inner # type: ignore 29 | 30 | 31 | @contextlib.contextmanager 32 | def profile(amount: int = 15) -> Iterator[None]: 33 | """Contextmanager to profile code sections. 34 | 35 | Starts a cProfile.Profile upon entry, disables it on exit and prints profiling 36 | information. 37 | 38 | Usage: 39 | with profile(amount=10): 40 | # your code to profile here 41 | ... 42 | # This is no longer profiled 43 | 44 | Args: 45 | amount: Number of lines to restrict the output to. 46 | """ 47 | cprofile = cProfile.Profile() 48 | cprofile.enable() 49 | yield 50 | cprofile.disable() 51 | stats = pstats.Stats(cprofile) 52 | stats.sort_stats("cumulative").print_stats(amount) 53 | stats.sort_stats("time").print_stats(amount) 54 | -------------------------------------------------------------------------------- /vimiv/utils/xdg.py: -------------------------------------------------------------------------------- 1 | # vim: ft=python fileencoding=utf-8 sw=4 et sts=4 2 | 3 | """Functions to help with XDG_USER_* settings.""" 4 | 5 | import os 6 | 7 | from vimiv.qt.core import QStandardPaths 8 | 9 | import vimiv 10 | 11 | 12 | basedir = None 13 | 14 | 15 | def _standardpath( 16 | location: QStandardPaths.StandardLocation, name: str, *paths: str 17 | ) -> str: 18 | """Return absolute path to a standard storage directory. 19 | 20 | Args: 21 | location: Location ID according to QStandardPaths. 22 | name: Fallback name to use in case there is a base directory. 23 | paths: Any additional paths passed to os.path.join. 24 | """ 25 | if basedir is not None: 26 | return os.path.join(basedir, name, *paths) 27 | return os.path.join(QStandardPaths.writableLocation(location), *paths) 28 | 29 | 30 | def makedirs(*paths: str) -> None: 31 | for path in paths: 32 | os.makedirs(path, mode=0o700, exist_ok=True) 33 | 34 | 35 | def user_data_dir(*paths: str) -> str: 36 | return _standardpath( 37 | QStandardPaths.StandardLocation.GenericDataLocation, "data", *paths 38 | ) 39 | 40 | 41 | def user_config_dir(*paths: str) -> str: 42 | return _standardpath( 43 | QStandardPaths.StandardLocation.GenericConfigLocation, "config", *paths 44 | ) 45 | 46 | 47 | def user_cache_dir(*paths: str) -> str: 48 | return _standardpath( 49 | QStandardPaths.StandardLocation.GenericCacheLocation, "cache", *paths 50 | ) 51 | 52 | 53 | def vimiv_data_dir(*paths: str) -> str: 54 | return user_data_dir(vimiv.__name__, *paths) 55 | 56 | 57 | def vimiv_cache_dir(*paths: str) -> str: 58 | return user_cache_dir(vimiv.__name__, *paths) 59 | 60 | 61 | def vimiv_config_dir(*paths: str) -> str: 62 | return user_config_dir(vimiv.__name__, *paths) 63 | --------------------------------------------------------------------------------