Skip to content

Commit

Permalink
Merge pull request #581 from Yutsuten/crop
Browse files Browse the repository at this point in the history
Add crop feature

fixes #8
  • Loading branch information
karlch authored Jan 17, 2023
2 parents c2ea874 + 11eee51 commit df8a4a4
Show file tree
Hide file tree
Showing 17 changed files with 572 additions and 33 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Christian Karl (karlch) <karlch at protonmail dot com>
Wolfgang Popp (woefe) <mail at wolfgang-popp dot de>
Ankur Sinha (sanjayankur31) <ankursinha at fedoraproject dot org>
Jean-Claude Graf (jcjgraf) <jeanggi90 at gmail dot com>
Mateus Etto (Yutsuten) <mateus dot etto at gmail dot com>

All contributors of non-trivial code are listed here. Please send an email to
<karlch at protonmail dot com> or open an issue / pull request if you feel like your
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,11 @@ Added:
* A new ``PromptSetting`` type which is essentially a boolean setting with the
additional ``ask`` value. If the value is ``ask``, the user is prompted everytime the
boolean state of this setting is requested.
* The ``:crop`` command which displays a rectangle to crop the curent image. The
rectangle can be dragged and resized using the mouse. As with ``:straighten``, accept
the changes with ``<return>`` and reject them with ``<escape>``. The
``{transformation-info}`` status module displays the currently selected geometry of
the original image.

Changed:
^^^^^^^^
Expand Down
5 changes: 5 additions & 0 deletions tests/end2end/features/completion/completion.feature
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ Feature: Using completion.
When I run command --text="!"
Then a possible completion should contain !ls

Scenario: Using crop completion
Given I open any image
When I run command --text="crop "
Then a possible completion should contain --aspectratio=keep

Scenario: Reset completions when leaving command mode
Given I open any directory
When I run command
Expand Down
32 changes: 31 additions & 1 deletion tests/end2end/features/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pathlib

from PyQt5.QtCore import Qt, QProcess, QTimer
from PyQt5.QtGui import QFocusEvent
from PyQt5.QtGui import QFocusEvent, QMouseEvent
from PyQt5.QtWidgets import QApplication

import pytest
Expand Down Expand Up @@ -177,6 +177,36 @@ def press_impl(widget, keys):
return press_impl


@pytest.fixture()
def mousedrag(qtbot):
"""Fixture to emulate a mouse drag on a widget.
Workaround for https://bugreports.qt.io/browse/QTBUG-5232.
"""

def drag(widget, *, start, diff):
end = start + diff

qtbot.mousePress(widget, Qt.LeftButton, pos=start)

global_end = widget.mapToGlobal(end)
button = buttons = Qt.NoButton
move_event = QMouseEvent(
QMouseEvent.MouseMove,
end,
global_end,
global_end,
button,
buttons,
Qt.NoModifier,
)
QApplication.sendEvent(widget, move_event)

qtbot.mouseRelease(widget, Qt.LeftButton, pos=end)

return drag


###############################################################################
# When #
###############################################################################
Expand Down
5 changes: 3 additions & 2 deletions tests/end2end/features/edit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.

import pytest
import pytest_bdd as bdd

from vimiv.parser import geometry
Expand All @@ -13,8 +14,8 @@
def ensure_size(size, image):
expected = geometry(size)
image_rect = image.sceneRect()
assert expected.width() == image_rect.width()
assert expected.height() == image_rect.height()
assert expected.width() == pytest.approx(image_rect.width(), abs=1)
assert expected.height() == pytest.approx(image_rect.height(), abs=1)


@bdd.then(bdd.parsers.parse("the image size should not be {size}"))
Expand Down
43 changes: 43 additions & 0 deletions tests/end2end/features/edit/crop.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
Feature: Crop an image.

Background:
Given I open any image of size 300x200

Scenario: Enter crop widget
When I run crop
Then there should be 1 crop widget
And the center status should include crop:

Scenario: Enter crop widget with fixed aspectratio
When I run crop --aspectratio=1:1
Then there should be 1 crop widget
And the center status should include crop:

Scenario: Leave crop widget without changes
When I run crop
And I press '<escape>' in the crop widget
Then there should be 0 crop widgets
And the image size should be 300x200

Scenario: Leave crop widget accepting changes
When I run crop
And I press '<return>' in the crop widget
Then there should be 0 crop widgets
And the image size should be 150x100

Scenario Outline: Drag crop widget with the mouse
When I run crop
And I drag the crop widget by <dx>+<dy>
Then the crop rectangle should be <geometry>

Examples:
| dx | dy | geometry |
| 0 | 0 | 150x100+75+50 |
# small dx dy
| 30 | -20 | 150x100+105+30 |
# dx only as far as the image allows
| 125 | 0 | 150x100+150+50 |
# dy only as far as the image allows
| 10 | -100 | 150x100+85+0 |
# Ignored as dx/dy are outside of the image
| 1000 | 1000 | 150x100+75+50 |
74 changes: 74 additions & 0 deletions tests/end2end/features/edit/test_crop_bdd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# vim: ft=python fileencoding=utf-8 sw=4 et sts=4

# This file is part of vimiv.
# Copyright 2017-2023 Christian Karl (karlch) <karlch at protonmail dot com>
# License: GNU GPL v3, see the "LICENSE" and "AUTHORS" files for details.

import re

from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtWidgets import QApplication

import pytest
import pytest_bdd as bdd

import vimiv.gui.crop_widget


bdd.scenarios("crop.feature")


def find_crop_widgets(image):
return image.findChildren(vimiv.gui.crop_widget.CropWidget)


@pytest.fixture()
def crop(image):
"""Fixture to retrieve the current instance of the crop widget."""
widgets = find_crop_widgets(image)
assert len(widgets) == 1, "Wrong number of crop wigets found"
return widgets[0]


@pytest.fixture()
def ensure_moving():
QApplication.setOverrideCursor(Qt.ClosedHandCursor)
yield
QApplication.restoreOverrideCursor()


@bdd.when(bdd.parsers.parse("I press '{keys}' in the crop widget"))
def press_key_crop(keypress, crop, keys):
keypress(crop, keys)


@bdd.then(bdd.parsers.parse("there should be {number:d} crop widgets"))
@bdd.then(bdd.parsers.parse("there should be {number:d} crop widget"))
def check_number_of_crop_widgets(qtbot, image, number):
def check():
assert len(find_crop_widgets(image)) == number

qtbot.waitUntil(check)


@bdd.when(bdd.parsers.parse("I drag the crop widget by {dx:d}+{dy:d}"))
@bdd.when("I drag the crop widget by <dx>+<dy>")
def drag_crop_widget(qtbot, mousedrag, crop, dx, dy):
dx = int(int(dx) * crop.image.zoom_level)
dy = int(int(dy) * crop.image.zoom_level)

start = QPoint(crop.width() // 2, crop.height() // 2)
mousedrag(crop, start=start, diff=QPoint(dx, dy))


@bdd.then(bdd.parsers.parse("the crop rectangle should be {geometry}"))
@bdd.then("the crop rectangle should be <geometry>")
def check_crop_rectangle(crop, geometry):
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
assert match is not None, "Invalid geometry passed"
width, height, x, y = match.groups()
rect = crop.crop_rect()
assert int(width) == pytest.approx(rect.width(), abs=1)
assert int(height) == pytest.approx(rect.height(), abs=1)
assert int(x) == pytest.approx(rect.x(), abs=1)
assert int(y) == pytest.approx(rect.y(), abs=1)
47 changes: 35 additions & 12 deletions tests/unit/commands/test_argtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,67 @@
from vimiv.commands import argtypes


def test_scroll_direction():
@pytest.mark.parametrize("name", ("left", "right", "up", "down"))
def test_scroll_direction(name):
# Would raise exception if a name is invalid
# pylint: disable=expression-not-assigned
[argtypes.Direction(name) for name in ["left", "right", "up", "down"]]
assert isinstance(argtypes.Direction(name), argtypes.Direction)


def test_fail_scroll_direction():
with pytest.raises(ValueError, match="not a valid Direct"):
argtypes.Direction("other")


def test_zoom():
@pytest.mark.parametrize("name", ("in", "out"))
def test_zoom(name):
# Would raise exception if a name is invalid
# pylint: disable=expression-not-assigned
[argtypes.Zoom(name) for name in ["in", "out"]]
assert isinstance(argtypes.Zoom(name), argtypes.Zoom)


def test_fail_zoom():
with pytest.raises(ValueError, match="not a valid Zoom"):
argtypes.Zoom("other")


def test_image_scale_text():
@pytest.mark.parametrize("name", ("fit", "fit-width", "fit-height"))
def test_image_scale_text(name):
# Would raise exception if a name is invalid
# pylint: disable=expression-not-assigned
[argtypes.ImageScaleFloat(name) for name in ["fit", "fit-width", "fit-height"]]
assert isinstance(argtypes.ImageScaleFloat(name), argtypes.ImageScale)


def test_image_scale_float():
assert argtypes.ImageScaleFloat("0.5") == 0.5


def test_command_history_direction():
@pytest.mark.parametrize("name", ("next", "prev"))
def test_command_history_direction(name):
# Would raise exception if a name is invalid
# pylint: disable=expression-not-assigned
[argtypes.HistoryDirection(name) for name in ["next", "prev"]]
assert isinstance(argtypes.HistoryDirection(name), argtypes.HistoryDirection)


def test_fail_command_history_direction():
with pytest.raises(ValueError, match="not a valid HistoryDirection"):
argtypes.HistoryDirection("other")


@pytest.mark.parametrize("size", ((3, 3), (4, 3), (16, 9)))
@pytest.mark.parametrize("separator", argtypes.AspectRatio.SEPARATORS)
def test_aspectratio(size, separator):
definition = separator.join(str(length) for length in size)
width, height = size
aspectratio = argtypes.AspectRatio(definition)
assert aspectratio.width() == int(width)
assert aspectratio.height() == int(height)
assert not aspectratio.keep


@pytest.mark.parametrize("definition", ("4to3", "4:3:2", "42", "hello:world"))
def test_fail_aspectratio(definition):
with pytest.raises(ValueError, match="Invalid aspectratio"):
argtypes.AspectRatio(definition)


@pytest.mark.parametrize("value", ("Keep", "keep", "keeP"))
def test_aspectratio_keep(value):
aspectratio = argtypes.AspectRatio(value)
assert aspectratio.keep
36 changes: 36 additions & 0 deletions vimiv/commands/argtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ def scroll(self, direction: argtypes.Direction):
...
"""

import re
import contextlib
import enum

from PyQt5.QtCore import QSize


class Direction(enum.Enum):
"""Valid arguments for directional commands."""
Expand Down Expand Up @@ -79,3 +82,36 @@ class HistoryDirection(enum.Enum):

Next = "next"
Prev = "prev"


class AspectRatio(QSize):
"""Aspectratio defined as QSize with width and height from a valid string.
Valid definitions are strings with two integer values representing width and height
separated by one of the characters in SEPARATORS.
Examples:
4:3
16,9
5_4
Attributes:
keep: True if the aspectratio of the original image should be kept.
"""

SEPARATORS = ":,-_"

def __init__(self, aspectratio: str):
if aspectratio.lower() == "keep":
self.keep = True
super().__init__()
else:
self.keep = False
split_re = "|".join(self.SEPARATORS)
try:
width, height = tuple(re.split(split_re, aspectratio))
super().__init__(int(width), int(height))
except ValueError:
raise ValueError(
f"'Invalid aspectratio '{aspectratio}'. Use width:height, e.g. 4:3"
) from None
10 changes: 10 additions & 0 deletions vimiv/completion/completionmodels.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,15 @@ def formatted_commands(self, mode: api.modes.Mode) -> List[Tuple[str, str]]:
]


class CropModel(api.completion.BaseModel):
"""Completion model filled with aspectratio options for :crop."""

def __init__(self):
super().__init__(":crop ")
options = ("", "--aspectratio=keep", "--aspectratio=16:9", "--aspectratio=4:3")
self.set_data((f":crop {option}",) for option in options)


def init():
"""Create completion models."""
CommandModel()
Expand All @@ -286,3 +295,4 @@ def init():
TagModel(suffix)
TrashModel()
HelpModel()
CropModel()
7 changes: 7 additions & 0 deletions vimiv/config/_style_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,11 @@
"prompt.border_radius": "{keyhint.border_radius}",
"prompt.border": "{statusbar.message_border}",
"prompt.border.color": "{statusbar.info}",
# Crop
"crop.shading": "#88000000",
"crop.border": "{manipulate.image.border}",
"crop.border.color": "#88AAAAAA",
"crop.grip.color": "#88FFFFFF",
"crop.grip.border": "{manipulate.image.border}",
"crop.grip.border.color": "{crop.border.color}",
}
Loading

0 comments on commit df8a4a4

Please sign in to comment.