diff --git a/CHANGELOG b/CHANGELOG index 2ee8687e..573c1b77 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,4 @@ +2024-11-03: [FEATURE] Add `ImageDecoration` for widgets 2024-10-19: [RELEASE] v0.29.0 release - compatible with qtile 0.29.0 2024-08-23: [FEATURE] Add `GradientDecoration` for widgets 2024-08-13: [RELEASE] v0.28.1 release - compatible with qtile 0.28.1 diff --git a/docs/_static/images/image_decoration.png b/docs/_static/images/image_decoration.png new file mode 100644 index 00000000..2af00d07 Binary files /dev/null and b/docs/_static/images/image_decoration.png differ diff --git a/qtile_extras/widget/decorations.py b/qtile_extras/widget/decorations.py index e1879b55..2641113d 100644 --- a/qtile_extras/widget/decorations.py +++ b/qtile_extras/widget/decorations.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021, elParaguayo. All rights reserved. +# Copyright (c) 2021-4, elParaguayo. All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal @@ -22,6 +22,7 @@ import copy import math from functools import partial +from pathlib import Path from typing import TYPE_CHECKING import cairocffi @@ -33,6 +34,8 @@ from libqtile.utils import rgb from libqtile.widget import Systray, base +from qtile_extras.images import Img + if TYPE_CHECKING: from typing import Any # noqa: F401 @@ -915,6 +918,108 @@ def pos(point): self.ctx.restore() +class ImageDecoration(_Decoration): + """ + Renders an image background to the widget. + + Setting ``whole_bar=True`` will draw the image by reference to the whole bar. Widgets will then + render the part of the image in the area covered by the widget. This allows a single image to be + applied consistently across all widgets. + """ + + _screenshots = [("image_decoration.png", "Image with 'whole_bar=True'.")] + + defaults = [ + ( + "whole_bar", + False, + "When set to ``True`` image is calculated by reference to the bar so " + "you can get a single image applied across multiple widgets.", + ), + ("image", "", "Path to background image"), + ( + "center", + True, + "Whether to center the image in the widget. If ``False`` image will be rendered frop top/left corner", + ), + ("fill", True, "Whether or not image should be resized to fit the widget/bar."), + ( + "preserve_aspect_ratio", + False, + "Whether aspect ratio should be preserved when resizing the image.", + ), + ] + + def __init__(self, **config): + _Decoration.__init__(self, **config) + self.add_defaults(ImageDecoration.defaults) + + if not self.image: + raise ConfigError("ImageDecoration has no image file.") + + self._image = Path(self.image).expanduser().resolve() + if not self._image.is_file(): + raise ConfigError(f"ImageDecoration cannot find {self.image}.") + + self._old_width = 0 + self._old_height = 0 + self._surface = None + + self._xoffset = 0 + self._yoffset = 0 + + def _get_image(self, width, height): + if self._surface and self._old_width == width and self._old_height == height: + return self._surface + + image = Img.from_path(self._image.as_posix()) + + if self.fill: + if self.preserve_aspect_ratio: + if image.width / image.height > width / height: + image.resize(height=height) + elif image.width / image.height < width / height: + image.resize(width=width) + else: + image.resize(width=width, height=height) + else: + image.resize(width=width, height=height) + + self._xoffset = ((width - image.width) // 2) if self.center else 0 + self._yoffset = ((height - image.height) // 2) if self.center else 0 + + self._old_width = width + self._old_height = height + + self._surface = image.pattern + return self._surface + + def draw(self): + width = self.parent.bar.width if self.whole_bar else self.width + height = self.parent.bar.height if self.whole_bar else self.height + + # Nothing to do if widget is hidden + if not (width and height): + return + + image = self._get_image(width, height) + + self.ctx.save() + self.ctx.rectangle(0, 0, self.width, self.height) + self.ctx.clip() + + # If we're using whole_bar then we shift 0, 0 to be top left corner of the bar + if self.whole_bar: + self.ctx.translate(-self.parent.offsetx, -self.parent.offsety) + + # Translate the image to position correctly for resizing/centering + self.ctx.translate(self._xoffset, self._yoffset) + + self.ctx.set_source(image) + self.ctx.paint() + self.ctx.restore() + + def inject_decorations(classdef): """ Method to inject ability for widgets to display decorations. diff --git a/test/resources/test_images/image-decoration-default-aspectratio-wholebar.png b/test/resources/test_images/image-decoration-default-aspectratio-wholebar.png new file mode 100644 index 00000000..a75911f7 Binary files /dev/null and b/test/resources/test_images/image-decoration-default-aspectratio-wholebar.png differ diff --git a/test/resources/test_images/image-decoration-default-aspectratio.png b/test/resources/test_images/image-decoration-default-aspectratio.png new file mode 100644 index 00000000..f928d93c Binary files /dev/null and b/test/resources/test_images/image-decoration-default-aspectratio.png differ diff --git a/test/resources/test_images/image-decoration-default-wholebar.png b/test/resources/test_images/image-decoration-default-wholebar.png new file mode 100644 index 00000000..5d8427da Binary files /dev/null and b/test/resources/test_images/image-decoration-default-wholebar.png differ diff --git a/test/resources/test_images/image-decoration-default.png b/test/resources/test_images/image-decoration-default.png new file mode 100644 index 00000000..d1e282a9 Binary files /dev/null and b/test/resources/test_images/image-decoration-default.png differ diff --git a/test/resources/test_images/image-decoration-nofill-aspectratio-nocenter-wholebar.png b/test/resources/test_images/image-decoration-nofill-aspectratio-nocenter-wholebar.png new file mode 100644 index 00000000..096817fe Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-aspectratio-nocenter-wholebar.png differ diff --git a/test/resources/test_images/image-decoration-nofill-aspectratio-nocenter.png b/test/resources/test_images/image-decoration-nofill-aspectratio-nocenter.png new file mode 100644 index 00000000..350c3323 Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-aspectratio-nocenter.png differ diff --git a/test/resources/test_images/image-decoration-nofill-aspectratio-wholebar.png b/test/resources/test_images/image-decoration-nofill-aspectratio-wholebar.png new file mode 100644 index 00000000..c8db4474 Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-aspectratio-wholebar.png differ diff --git a/test/resources/test_images/image-decoration-nofill-aspectratio.png b/test/resources/test_images/image-decoration-nofill-aspectratio.png new file mode 100644 index 00000000..3b1286ce Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-aspectratio.png differ diff --git a/test/resources/test_images/image-decoration-nofill-nocenter-wholebar.png b/test/resources/test_images/image-decoration-nofill-nocenter-wholebar.png new file mode 100644 index 00000000..096817fe Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-nocenter-wholebar.png differ diff --git a/test/resources/test_images/image-decoration-nofill-nocenter.png b/test/resources/test_images/image-decoration-nofill-nocenter.png new file mode 100644 index 00000000..350c3323 Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-nocenter.png differ diff --git a/test/resources/test_images/image-decoration-nofill-wholebar.png b/test/resources/test_images/image-decoration-nofill-wholebar.png new file mode 100644 index 00000000..c8db4474 Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill-wholebar.png differ diff --git a/test/resources/test_images/image-decoration-nofill.png b/test/resources/test_images/image-decoration-nofill.png new file mode 100644 index 00000000..3b1286ce Binary files /dev/null and b/test/resources/test_images/image-decoration-nofill.png differ diff --git a/test/widget/decorations/test_decoration_output.py b/test/widget/decorations/test_decoration_output.py index 823b90e4..58954102 100644 --- a/test/widget/decorations/test_decoration_output.py +++ b/test/widget/decorations/test_decoration_output.py @@ -17,16 +17,23 @@ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from pathlib import Path + import pytest from qtile_extras import widget from qtile_extras.widget.decorations import ( BorderDecoration, GradientDecoration, + ImageDecoration, PowerLineDecoration, RectDecoration, ) +BACKGROUND = ( + (Path(__file__).parent / ".." / "resources" / "image_background.png").resolve().as_posix() +) + def widgets(decorations=list()): return [ @@ -251,6 +258,108 @@ def widgets(decorations=list()): } ) +# IMAGEDECORATION +params.append( + { + "name": "image-decoration-default", + "widgets": widgets([ImageDecoration(image=BACKGROUND)]), + } +) +params.append( + { + "name": "image-decoration-default-aspectratio", + "widgets": widgets([ImageDecoration(image=BACKGROUND, preserve_aspect_ratio=True)]), + } +) +params.append( + { + "name": "image-decoration-nofill", + "widgets": widgets([ImageDecoration(image=BACKGROUND, fill=False)]), + } +) +params.append( + { + "name": "image-decoration-nofill-nocenter", + "widgets": widgets([ImageDecoration(image=BACKGROUND, fill=False, center=False)]), + } +) +params.append( + { + "name": "image-decoration-nofill-aspectratio", + "widgets": widgets( + [ImageDecoration(image=BACKGROUND, fill=False, preserve_aspect_ratio=True)] + ), + } +) +params.append( + { + "name": "image-decoration-nofill-aspectratio-nocenter", + "widgets": widgets( + [ + ImageDecoration( + image=BACKGROUND, fill=False, preserve_aspect_ratio=True, center=False + ) + ] + ), + } +) +params.append( + { + "name": "image-decoration-default-wholebar", + "widgets": widgets([ImageDecoration(image=BACKGROUND, whole_bar=True)]), + } +) +params.append( + { + "name": "image-decoration-default-aspectratio-wholebar", + "widgets": widgets( + [ImageDecoration(image=BACKGROUND, preserve_aspect_ratio=True, whole_bar=True)] + ), + } +) +params.append( + { + "name": "image-decoration-nofill-wholebar", + "widgets": widgets([ImageDecoration(image=BACKGROUND, fill=False, whole_bar=True)]), + } +) +params.append( + { + "name": "image-decoration-nofill-nocenter-wholebar", + "widgets": widgets( + [ImageDecoration(image=BACKGROUND, fill=False, center=False, whole_bar=True)] + ), + } +) +params.append( + { + "name": "image-decoration-nofill-aspectratio-wholebar", + "widgets": widgets( + [ + ImageDecoration( + image=BACKGROUND, fill=False, preserve_aspect_ratio=True, whole_bar=True + ) + ] + ), + } +) +params.append( + { + "name": "image-decoration-nofill-aspectratio-nocenter-wholebar", + "widgets": widgets( + [ + ImageDecoration( + image=BACKGROUND, + fill=False, + preserve_aspect_ratio=True, + center=False, + whole_bar=True, + ) + ] + ), + } +) + # COMBOS decorations = [ RectDecoration( diff --git a/test/widget/decorations/test_widget_decorations.py b/test/widget/decorations/test_widget_decorations.py index fbc53a18..5e1c231e 100644 --- a/test/widget/decorations/test_widget_decorations.py +++ b/test/widget/decorations/test_widget_decorations.py @@ -18,21 +18,45 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import logging +import tempfile +from pathlib import Path +import cairocffi import libqtile.bar import libqtile.config import pytest from libqtile.log_utils import init_log +from libqtile.utils import rgb from qtile_extras import widget from qtile_extras.widget.decorations import ( BorderDecoration, + ImageDecoration, PowerLineDecoration, RectDecoration, _Decoration, ) +@pytest.fixture(scope="function") +def image_background(): + with tempfile.TemporaryDirectory() as img_dir: + output = Path(img_dir) / "image_background.png" + + img = cairocffi.ImageSurface(cairocffi.FORMAT_ARGB32, 100, 100) + + with cairocffi.Context(img) as ctx: + lg = cairocffi.LinearGradient(0, 0, 100, 100) + lg.add_color_stop_rgba(0, *rgb("f0f")) + lg.add_color_stop_rgba(1, *rgb("0ff")) + ctx.set_source(lg) + ctx.paint() + + img.write_to_png(output.as_posix()) + + yield output.as_posix() + + def test_single_or_four(): for value, expected in [ (1, [1, 1, 1, 1]), @@ -272,3 +296,40 @@ def assert_first_last(widget, first, last): # Last widget is not grouped assert_first_last(widget6, True, True) + + +@pytest.mark.parametrize( + "kwargs,xoffset,yoffset", + [ + ({}, 0, 0), + ({"preserve_aspect_ratio": True}, 0, -30), # (40 - 100) // 2 + ({"preserve_aspect_ratio": True, "center": False}, 0, 0), # Overrides offset + ], +) +def test_image_decoration( + manager_nospawn, minimal_conf_noscreen, image_background, kwargs, xoffset, yoffset +): + config = minimal_conf_noscreen + + config.screens = [ + libqtile.config.Screen( + top=libqtile.bar.Bar( + [ + widget.TextBox( + "Text 1", + width=100, + decorations=[ImageDecoration(image=image_background, **kwargs)], + ) + ], + 40, + ) + ) + ] + + manager_nospawn.start(config) + + tb = manager_nospawn.c.widget["textbox"] + _, ox = tb.eval("self.decorations[0]._xoffset") + _, oy = tb.eval("self.decorations[0]._yoffset") + assert int(ox) == xoffset + assert int(oy) == yoffset diff --git a/test/widget/resources/image_background.png b/test/widget/resources/image_background.png new file mode 100644 index 00000000..3e11c8e1 Binary files /dev/null and b/test/widget/resources/image_background.png differ