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