1import abc
2import enum
3import os.path
4
5import attr
6
7import ueberzug.geometry as geometry
8import ueberzug.scaling as scaling
9import ueberzug.conversion as conversion
10
11
12@attr.s
13class Action(metaclass=abc.ABCMeta):
14    """Describes the structure used to define actions classes.
15
16    Defines a general interface used to implement the building of commands
17    and their execution.
18    """
19    action = attr.ib(type=str, default=attr.Factory(
20        lambda self: self.get_action_name(), takes_self=True))
21
22    @staticmethod
23    @abc.abstractmethod
24    def get_action_name():
25        """Returns the constant name which is associated to this action."""
26        raise NotImplementedError()
27
28    @abc.abstractmethod
29    async def apply(self, windows, view, tools):
30        """Executes the action on  the passed view and windows."""
31        raise NotImplementedError()
32
33
34@attr.s(kw_only=True)
35class Drawable:
36    """Defines the attributes of drawable actions."""
37    draw = attr.ib(default=True, converter=conversion.to_bool)
38    synchronously_draw = attr.ib(default=False, converter=conversion.to_bool)
39
40
41@attr.s(kw_only=True)
42class Identifiable:
43    """Defines the attributes of actions
44    which are associated to an identifier.
45    """
46    identifier = attr.ib(type=str)
47
48
49@attr.s(kw_only=True)
50class DrawAction(Action, Drawable, metaclass=abc.ABCMeta):
51    """Defines actions which redraws all windows."""
52    # pylint: disable=abstract-method
53    __redraw_scheduled = False
54
55    @staticmethod
56    def schedule_redraw(windows):
57        """Creates a async function which redraws every window
58        if there is no unexecuted function
59        (returned by this function)
60        which does the same.
61
62        Args:
63            windows (batch.BatchList of ui.OverlayWindow):
64                the windows to be redrawn
65
66        Returns:
67            function: the redraw function or None
68        """
69        if not DrawAction.__redraw_scheduled:
70            DrawAction.__redraw_scheduled = True
71
72            async def redraw():
73                windows.draw()
74                DrawAction.__redraw_scheduled = False
75            return redraw()
76        return None
77
78    async def apply(self, windows, view, tools):
79        if self.draw:
80            import asyncio
81            if self.synchronously_draw:
82                windows.draw()
83                # force coroutine switch
84                await asyncio.sleep(0)
85                return
86
87            function = self.schedule_redraw(windows)
88            if function:
89                asyncio.ensure_future(function)
90
91
92@attr.s(kw_only=True)
93class ImageAction(DrawAction, Identifiable, metaclass=abc.ABCMeta):
94    """Defines actions which are related to images."""
95    # pylint: disable=abstract-method
96    pass
97
98
99@attr.s(kw_only=True)
100class AddImageAction(ImageAction):
101    """Displays the image according to the passed option.
102    If there's already an image with the given identifier
103    it's going to be replaced.
104    """
105
106    x = attr.ib(type=int, converter=int)
107    y = attr.ib(type=int, converter=int)
108    path = attr.ib(type=str)
109    width = attr.ib(type=int, converter=int, default=0)
110    height = attr.ib(type=int, converter=int, default=0)
111    scaling_position_x = attr.ib(type=float, converter=float, default=0)
112    scaling_position_y = attr.ib(type=float, converter=float, default=0)
113    scaler = attr.ib(
114        type=str, default=scaling.ContainImageScaler.get_scaler_name())
115    # deprecated
116    max_width = attr.ib(type=int, converter=int, default=0)
117    max_height = attr.ib(type=int, converter=int, default=0)
118
119    @staticmethod
120    def get_action_name():
121        return 'add'
122
123    def __attrs_post_init__(self):
124        self.width = self.max_width or self.width
125        self.height = self.max_height or self.height
126        # attrs doesn't support overriding the init method
127        # pylint: disable=attribute-defined-outside-init
128        self.__scaler_class = None
129        self.__last_modified = None
130
131    @property
132    def scaler_class(self):
133        """scaling.ImageScaler: the used scaler class of this placement"""
134        if self.__scaler_class is None:
135            self.__scaler_class = \
136                scaling.ScalerOption(self.scaler).scaler_class
137        return self.__scaler_class
138
139    @property
140    def last_modified(self):
141        """float: the last modified time of the image"""
142        if self.__last_modified is None:
143            self.__last_modified = os.path.getmtime(self.path)
144        return self.__last_modified
145
146    def is_same_image(self, old_placement):
147        """Determines whether the placement contains the same image
148        after applying the changes of this command.
149
150        Args:
151            old_placement (ui.OverlayWindow.Placement):
152                the old data of the placement
153
154        Returns:
155            bool: True if it's the same file
156        """
157        return old_placement and not (
158            old_placement.last_modified < self.last_modified
159            or self.path != old_placement.path)
160
161    def is_full_reload_required(self, old_placement,
162                                screen_columns, screen_rows):
163        """Determines whether it's required to fully reload
164        the image of the placement to properly render the placement.
165
166        Args:
167            old_placement (ui.OverlayWindow.Placement):
168                the old data of the placement
169            screen_columns (float):
170                the maximum amount of columns the screen can display
171            screen_rows (float):
172                the maximum amount of rows the screen can display
173
174        Returns:
175            bool: True if the image should be reloaded
176        """
177        return old_placement and (
178            (not self.scaler_class.is_indulgent_resizing()
179             and old_placement.scaler.is_indulgent_resizing())
180            or (old_placement.width <= screen_columns < self.width)
181            or (old_placement.height <= screen_rows < self.height))
182
183    def is_partly_reload_required(self, old_placement,
184                                  screen_columns, screen_rows):
185        """Determines whether it's required to partly reload
186        the image of the placement to render the placement more quickly.
187
188        Args:
189            old_placement (ui.OverlayWindow.Placement):
190                the old data of the placement
191            screen_columns (float):
192                the maximum amount of columns the screen can display
193            screen_rows (float):
194                the maximum amount of rows the screen can display
195
196        Returns:
197            bool: True if the image should be reloaded
198        """
199        return old_placement and (
200            (self.scaler_class.is_indulgent_resizing()
201             and not old_placement.scaler.is_indulgent_resizing())
202            or (self.width <= screen_columns < old_placement.width)
203            or (self.height <= screen_rows < old_placement.height))
204
205    async def apply(self, windows, view, tools):
206        try:
207            import ueberzug.ui as ui
208            import ueberzug.loading as loading
209            old_placement = view.media.pop(self.identifier, None)
210            cache = old_placement and old_placement.cache
211            image = old_placement and old_placement.image
212
213            max_font_width = max(map(
214                lambda i: i or 0, windows.parent_info.font_width or [0]))
215            max_font_height = max(map(
216                lambda i: i or 0, windows.parent_info.font_height or [0]))
217            font_size_available = max_font_width and max_font_height
218            screen_columns = (font_size_available and
219                              view.screen_width / max_font_width)
220            screen_rows = (font_size_available and
221                           view.screen_height / max_font_height)
222
223            # By default images are only stored up to a resolution which
224            # is about as big as the screen resolution.
225            # (loading.CoverPostLoadImageProcessor)
226            # The principle of spatial locality does not apply to
227            # resize operations of images with big resolutions
228            # which is why those operations should be applied
229            # to a resized version of those images.
230            # Sometimes we still need all pixels e.g.
231            # if the image scaler crop is used.
232            # So sometimes it's required to fully load them
233            # and sometimes it's not required anymore which is
234            # why they should be partly reloaded
235            # (to speed up the resize operations again).
236            if (not self.is_same_image(old_placement)
237                    or (font_size_available and self.is_full_reload_required(
238                        old_placement, screen_columns, screen_rows))
239                    or (font_size_available and self.is_partly_reload_required(
240                        old_placement, screen_columns, screen_rows))):
241                upper_bound_size = None
242                image_post_load_processor = None
243                if (self.scaler_class != scaling.CropImageScaler and
244                        font_size_available):
245                    upper_bound_size = (
246                        max_font_width * self.width,
247                        max_font_height * self.height)
248                if (self.scaler_class != scaling.CropImageScaler
249                        and font_size_available
250                        and self.width <= screen_columns
251                        and self.height <= screen_rows):
252                    image_post_load_processor = \
253                        loading.CoverPostLoadImageProcessor(
254                            view.screen_width, view.screen_height)
255                image = tools.loader.load(
256                    self.path, upper_bound_size, image_post_load_processor)
257                cache = None
258
259            view.media[self.identifier] = ui.OverlayWindow.Placement(
260                self.x, self.y, self.width, self.height,
261                geometry.Point(self.scaling_position_x,
262                               self.scaling_position_y),
263                self.scaler_class(),
264                self.path, image, self.last_modified, cache)
265        finally:
266            await super().apply(windows, view, tools)
267
268
269@attr.s(kw_only=True)
270class RemoveImageAction(ImageAction):
271    """Removes the image with the passed identifier."""
272
273    @staticmethod
274    def get_action_name():
275        return 'remove'
276
277    async def apply(self, windows, view, tools):
278        try:
279            if self.identifier in view.media:
280                del view.media[self.identifier]
281        finally:
282            await super().apply(windows, view, tools)
283
284
285@enum.unique
286class Command(str, enum.Enum):
287    ADD = AddImageAction
288    REMOVE = RemoveImageAction
289
290    def __new__(cls, action_class):
291        inst = str.__new__(cls)
292        inst._value_ = action_class.get_action_name()
293        inst.action_class = action_class
294        return inst
295