Mercurial > libervia-backend
comparison libervia/frontends/quick_frontend/quick_widgets.py @ 4074:26b7ed2817da
refactoring: rename `sat_frontends` to `libervia.frontends`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 14:12:38 +0200 |
parents | sat_frontends/quick_frontend/quick_widgets.py@4b842c1fb686 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4073:7c5654c54fed | 4074:26b7ed2817da |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 | |
4 # helper class for making a SAT frontend | |
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
6 | |
7 # This program is free software: you can redistribute it and/or modify | |
8 # it under the terms of the GNU Affero General Public License as published by | |
9 # the Free Software Foundation, either version 3 of the License, or | |
10 # (at your option) any later version. | |
11 | |
12 # This program is distributed in the hope that it will be useful, | |
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
15 # GNU Affero General Public License for more details. | |
16 | |
17 # You should have received a copy of the GNU Affero General Public License | |
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
19 | |
20 from libervia.backend.core.log import getLogger | |
21 | |
22 log = getLogger(__name__) | |
23 from libervia.backend.core import exceptions | |
24 from libervia.frontends.quick_frontend.constants import Const as C | |
25 | |
26 | |
27 classes_map = {} | |
28 | |
29 | |
30 try: | |
31 # FIXME: to be removed when an acceptable solution is here | |
32 str("") # XXX: unicode doesn't exist in pyjamas | |
33 except ( | |
34 TypeError, | |
35 AttributeError, | |
36 ): # Error raised is not the same depending on pyjsbuild options | |
37 str = str | |
38 | |
39 | |
40 def register(base_cls, child_cls=None): | |
41 """Register a child class to use by default when a base class is needed | |
42 | |
43 @param base_cls: "Quick..." base class (like QuickChat or QuickContact), must inherit from QuickWidget | |
44 @param child_cls: inherited class to use when Quick... class is requested, must inherit from base_cls. | |
45 Can be None if it's the base_cls itself which register | |
46 """ | |
47 # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas because | |
48 # in the second case | |
49 classes_map[base_cls.__name__] = child_cls | |
50 | |
51 | |
52 class WidgetAlreadyExistsError(Exception): | |
53 pass | |
54 | |
55 | |
56 class QuickWidgetsManager(object): | |
57 """This class is used to manage all the widgets of a frontend | |
58 A widget can be a window, a graphical thing, or someting else depending of the frontend""" | |
59 | |
60 def __init__(self, host): | |
61 self.host = host | |
62 self._widgets = {} | |
63 | |
64 def __iter__(self): | |
65 """Iterate throught all widgets""" | |
66 for widget_map in self._widgets.values(): | |
67 for widget_instances in widget_map.values(): | |
68 for widget in widget_instances: | |
69 yield widget | |
70 | |
71 def get_real_class(self, class_): | |
72 """Return class registered for given class_ | |
73 | |
74 @param class_: subclass of QuickWidget | |
75 @return: class actually used to create widget | |
76 """ | |
77 try: | |
78 # FIXME: we use base_cls.__name__ instead of base_cls directly because pyjamas bugs | |
79 # in the second case | |
80 cls = classes_map[class_.__name__] | |
81 except KeyError: | |
82 cls = class_ | |
83 if cls is None: | |
84 raise exceptions.InternalError( | |
85 "There is not class registered for {}".format(class_) | |
86 ) | |
87 return cls | |
88 | |
89 def get_widget_instances(self, widget): | |
90 """Get all instance of a widget | |
91 | |
92 This is a helper method which call get_widgets | |
93 @param widget(QuickWidget): retrieve instances of this widget | |
94 @return: iterator on widgets | |
95 """ | |
96 return self.get_widgets(widget.__class__, widget.target, widget.profiles) | |
97 | |
98 def get_widgets(self, class_, target=None, profiles=None, with_duplicates=True): | |
99 """Get all subclassed widgets instances | |
100 | |
101 @param class_: subclass of QuickWidget, same parameter as used in | |
102 [get_or_create_widget] | |
103 @param target: if not None, construct a hash with this target and filter | |
104 corresponding widgets | |
105 recreated widgets are handled | |
106 @param profiles(iterable, None): if not None, filter on instances linked to these | |
107 profiles | |
108 @param with_duplicates(bool): if False, only first widget with a given hash is | |
109 returned | |
110 @return: iterator on widgets | |
111 """ | |
112 class_ = self.get_real_class(class_) | |
113 try: | |
114 widgets_map = self._widgets[class_.__name__] | |
115 except KeyError: | |
116 return | |
117 else: | |
118 if target is not None: | |
119 filter_hash = str(class_.get_widget_hash(target, profiles)) | |
120 else: | |
121 filter_hash = None | |
122 if filter_hash is not None: | |
123 for widget in widgets_map.get(filter_hash, []): | |
124 yield widget | |
125 if not with_duplicates: | |
126 return | |
127 else: | |
128 for widget_instances in widgets_map.values(): | |
129 for widget in widget_instances: | |
130 yield widget | |
131 if not with_duplicates: | |
132 # widgets are set by hashes, so if don't want duplicates | |
133 # we only return the first widget of the list | |
134 break | |
135 | |
136 def get_widget(self, class_, target=None, profiles=None): | |
137 """Get a widget without creating it if it doesn't exist. | |
138 | |
139 if several instances of widgets with this hash exist, the first one is returned | |
140 @param class_: subclass of QuickWidget, same parameter as used in [get_or_create_widget] | |
141 @param target: target depending of the widget, usually a JID instance | |
142 @param profiles (unicode, iterable[unicode], None): profile(s) to use (may or may not be | |
143 used, depending of the widget class) | |
144 @return: a class_ instance or None if the widget doesn't exist | |
145 """ | |
146 assert (target is not None) or (profiles is not None) | |
147 if profiles is not None and isinstance(profiles, str): | |
148 profiles = [profiles] | |
149 class_ = self.get_real_class(class_) | |
150 hash_ = class_.get_widget_hash(target, profiles) | |
151 try: | |
152 return self._widgets[class_.__name__][hash_][0] | |
153 except KeyError: | |
154 return None | |
155 | |
156 def get_or_create_widget(self, class_, target, *args, **kwargs): | |
157 """Get an existing widget or create a new one when necessary | |
158 | |
159 If the widget is new, self.host.new_widget will be called with it. | |
160 @param class_(class): class of the widget to create | |
161 @param target: target depending of the widget, usually a JID instance | |
162 @param args(list): optional args to create a new instance of class_ | |
163 @param kwargs(dict): optional kwargs to create a new instance of class_ | |
164 if 'profile' key is present, it will be popped and put in 'profiles' | |
165 if there is neither 'profile' nor 'profiles', None will be used for 'profiles' | |
166 if 'on_new_widget' is present it can have the following values: | |
167 C.WIDGET_NEW [default]: self.host.new_widget will be called on widget creation | |
168 [callable]: this method will be called instead of self.host.new_widget | |
169 None: do nothing | |
170 if 'on_existing_widget' is present it can have the following values: | |
171 C.WIDGET_KEEP [default]: return the existing widget | |
172 C.WIDGET_RAISE: raise WidgetAlreadyExistsError | |
173 C.WIDGET_RECREATE: create a new widget | |
174 if the existing widget has a "recreate_args" method, it will be called with args list and kwargs dict | |
175 so the values can be completed to create correctly the new instance | |
176 [callable]: this method will be called with existing widget as argument, the widget to use must be returned | |
177 if 'force_hash' is present, the hash given in value will be used instead of the one returned by class_.get_widget_hash | |
178 other keys will be used to instanciate class_ if the case happen (e.g. if type_ is present and class_ is a QuickChat subclass, | |
179 it will be used to create a new QuickChat instance). | |
180 @return: a class_ instance, either new or already existing | |
181 """ | |
182 cls = self.get_real_class(class_) | |
183 | |
184 ## arguments management ## | |
185 _args = [self.host, target] + list( | |
186 args | |
187 ) or [] # FIXME: check if it's really necessary to use optional args | |
188 _kwargs = kwargs or {} | |
189 if "profiles" in _kwargs and "profile" in _kwargs: | |
190 raise ValueError( | |
191 "You can't have 'profile' and 'profiles' keys at the same time" | |
192 ) | |
193 try: | |
194 _kwargs["profiles"] = [_kwargs.pop("profile")] | |
195 except KeyError: | |
196 if not "profiles" in _kwargs: | |
197 _kwargs["profiles"] = None | |
198 | |
199 # on_new_widget tells what to do for the new widget creation | |
200 try: | |
201 on_new_widget = _kwargs.pop("on_new_widget") | |
202 except KeyError: | |
203 on_new_widget = C.WIDGET_NEW | |
204 | |
205 # on_existing_widget tells what to do when the widget already exists | |
206 try: | |
207 on_existing_widget = _kwargs.pop("on_existing_widget") | |
208 except KeyError: | |
209 on_existing_widget = C.WIDGET_KEEP | |
210 | |
211 ## we get the hash ## | |
212 try: | |
213 hash_ = _kwargs.pop("force_hash") | |
214 except KeyError: | |
215 hash_ = cls.get_widget_hash(target, _kwargs["profiles"]) | |
216 | |
217 ## widget creation or retrieval ## | |
218 | |
219 widgets_map = self._widgets.setdefault( | |
220 cls.__name__, {} | |
221 ) # we sorts widgets by classes | |
222 if not cls.SINGLE: | |
223 widget = None # if the class is not SINGLE, we always create a new widget | |
224 else: | |
225 try: | |
226 widget = widgets_map[hash_][0] | |
227 except KeyError: | |
228 widget = None | |
229 else: | |
230 widget.add_target(target) | |
231 | |
232 if widget is None: | |
233 # we need to create a new widget | |
234 log.debug(f"Creating new widget for target {target} {cls}") | |
235 widget = cls(*_args, **_kwargs) | |
236 widgets_map.setdefault(hash_, []).append(widget) | |
237 self.host.call_listeners("widgetNew", widget) | |
238 | |
239 if on_new_widget == C.WIDGET_NEW: | |
240 self.host.new_widget(widget) | |
241 elif callable(on_new_widget): | |
242 on_new_widget(widget) | |
243 else: | |
244 assert on_new_widget is None | |
245 else: | |
246 # the widget already exists | |
247 if on_existing_widget == C.WIDGET_KEEP: | |
248 pass | |
249 elif on_existing_widget == C.WIDGET_RAISE: | |
250 raise WidgetAlreadyExistsError(hash_) | |
251 elif on_existing_widget == C.WIDGET_RECREATE: | |
252 try: | |
253 recreate_args = widget.recreate_args | |
254 except AttributeError: | |
255 pass | |
256 else: | |
257 recreate_args(_args, _kwargs) | |
258 widget = cls(*_args, **_kwargs) | |
259 widgets_map[hash_].append(widget) | |
260 log.debug("widget <{wid}> already exists, a new one has been recreated" | |
261 .format(wid=widget)) | |
262 elif callable(on_existing_widget): | |
263 widget = on_existing_widget(widget) | |
264 if widget is None: | |
265 raise exceptions.InternalError( | |
266 "on_existing_widget method must return the widget to use") | |
267 if widget not in widgets_map[hash_]: | |
268 log.debug( | |
269 "the widget returned by on_existing_widget is new, adding it") | |
270 widgets_map[hash_].append(widget) | |
271 else: | |
272 raise exceptions.InternalError( | |
273 "Unexpected on_existing_widget value ({})".format(on_existing_widget)) | |
274 | |
275 return widget | |
276 | |
277 def delete_widget(self, widget_to_delete, *args, **kwargs): | |
278 """Delete a widget instance | |
279 | |
280 this method must be called by frontends when a widget is deleted | |
281 widget's on_delete method will be called before deletion, and deletion will be | |
282 stopped if it returns False. | |
283 @param widget_to_delete(QuickWidget): widget which need to deleted | |
284 @param *args: extra arguments to pass to on_delete | |
285 @param *kwargs: extra keywords arguments to pass to on_delete | |
286 the extra arguments are not used by QuickFrontend, it's is up to | |
287 the frontend to use them or not. | |
288 following extra arguments are well known: | |
289 - "all_instances" can be used as kwarg, if it evaluate to True, | |
290 all instances of the widget will be deleted (if on_delete is | |
291 not returning False for any of the instance). This arguments | |
292 is not sent to on_delete methods. | |
293 - "explicit_close" is used when the deletion is requested by | |
294 the user or a leave signal, "all_instances" is usually set at | |
295 the same time. | |
296 """ | |
297 # TODO: all_instances must be independante kwargs, this is not possible with Python 2 | |
298 # but will be with Python 3 | |
299 all_instances = kwargs.get('all_instances', False) | |
300 | |
301 if all_instances: | |
302 for w in self.get_widget_instances(widget_to_delete): | |
303 if w.on_delete(**kwargs) == False: | |
304 log.debug( | |
305 f"Deletion of {widget_to_delete} cancelled by widget itself") | |
306 return | |
307 else: | |
308 if widget_to_delete.on_delete(**kwargs) == False: | |
309 log.debug(f"Deletion of {widget_to_delete} cancelled by widget itself") | |
310 return | |
311 | |
312 if self.host.selected_widget == widget_to_delete: | |
313 self.host.selected_widget = None | |
314 | |
315 class_ = self.get_real_class(widget_to_delete.__class__) | |
316 try: | |
317 widgets_map = self._widgets[class_.__name__] | |
318 except KeyError: | |
319 log.error("no widgets_map found for class {cls}".format(cls=class_)) | |
320 return | |
321 widget_hash = str(class_.get_widget_hash(widget_to_delete.target, | |
322 widget_to_delete.profiles)) | |
323 try: | |
324 widget_instances = widgets_map[widget_hash] | |
325 except KeyError: | |
326 log.error(f"no instance of {class_.__name__} found with hash {widget_hash!r}") | |
327 return | |
328 if all_instances: | |
329 widget_instances.clear() | |
330 else: | |
331 try: | |
332 widget_instances.remove(widget_to_delete) | |
333 except ValueError: | |
334 log.error("widget_to_delete not found in widget instances") | |
335 return | |
336 | |
337 log.debug("widget {} deleted".format(widget_to_delete)) | |
338 | |
339 if not widget_instances: | |
340 # all instances with this hash have been deleted | |
341 # we remove the hash itself | |
342 del widgets_map[widget_hash] | |
343 log.debug("All instances of {cls} with hash {widget_hash!r} have been deleted" | |
344 .format(cls=class_, widget_hash=widget_hash)) | |
345 self.host.call_listeners("widgetDeleted", widget_to_delete) | |
346 | |
347 | |
348 class QuickWidget(object): | |
349 """generic widget base""" | |
350 # FIXME: sometime a single target is used, sometimes several ones | |
351 # This should be sorted out in the same way as for profiles: a single | |
352 # target should be possible when appropriate attribute is set. | |
353 # methods using target(s) and hash should be fixed accordingly | |
354 | |
355 SINGLE = True # if True, there can be only one widget per target(s) | |
356 PROFILES_MULTIPLE = False # If True, this widget can handle several profiles at once | |
357 PROFILES_ALLOW_NONE = False # If True, this widget can be used without profile | |
358 | |
359 def __init__(self, host, target, profiles=None): | |
360 """ | |
361 @param host: %(doc_host)s | |
362 @param target: target specific for this widget class | |
363 @param profiles: can be either: | |
364 - (unicode): used when widget class manage a unique profile | |
365 - (iterable): some widget class can manage several profiles, several at once can be specified here | |
366 - None: no profile is managed by this widget class (rare) | |
367 @raise: ValueError when (iterable) or None is given to profiles for a widget class which manage one unique profile. | |
368 """ | |
369 self.host = host | |
370 self.targets = set() | |
371 self.add_target(target) | |
372 self.profiles = set() | |
373 self._sync = True | |
374 if isinstance(profiles, str): | |
375 self.add_profile(profiles) | |
376 elif profiles is None: | |
377 if not self.PROFILES_ALLOW_NONE: | |
378 raise ValueError("profiles can't have a value of None") | |
379 else: | |
380 for profile in profiles: | |
381 self.add_profile(profile) | |
382 if not self.profiles: | |
383 raise ValueError("no profile found, use None for no profile classes") | |
384 | |
385 @property | |
386 def profile(self): | |
387 assert ( | |
388 len(self.profiles) == 1 | |
389 and not self.PROFILES_MULTIPLE | |
390 and not self.PROFILES_ALLOW_NONE | |
391 ) | |
392 return list(self.profiles)[0] | |
393 | |
394 @property | |
395 def target(self): | |
396 """Return main target | |
397 | |
398 A random target is returned when several targets are available | |
399 """ | |
400 return next(iter(self.targets)) | |
401 | |
402 @property | |
403 def widget_hash(self): | |
404 """Return quick widget hash""" | |
405 return self.get_widget_hash(self.target, self.profiles) | |
406 | |
407 # synchronisation state | |
408 | |
409 @property | |
410 def sync(self): | |
411 return self._sync | |
412 | |
413 @sync.setter | |
414 def sync(self, state): | |
415 """state of synchronisation with backend | |
416 | |
417 @param state(bool): True when backend is synchronised | |
418 False is set by core | |
419 True must be set by the widget when resynchronisation is finished | |
420 """ | |
421 self._sync = state | |
422 | |
423 def resync(self): | |
424 """Method called when backend can be resynchronized | |
425 | |
426 The widget has to set self.sync itself when the synchronisation is finished | |
427 """ | |
428 pass | |
429 | |
430 # target/profile | |
431 | |
432 def add_target(self, target): | |
433 """Add a target if it doesn't already exists | |
434 | |
435 @param target: target to add | |
436 """ | |
437 self.targets.add(target) | |
438 | |
439 def add_profile(self, profile): | |
440 """Add a profile is if doesn't already exists | |
441 | |
442 @param profile: profile to add | |
443 """ | |
444 if self.profiles and not self.PROFILES_MULTIPLE: | |
445 raise ValueError("multiple profiles are not allowed") | |
446 self.profiles.add(profile) | |
447 | |
448 # widget identitication | |
449 | |
450 @staticmethod | |
451 def get_widget_hash(target, profiles): | |
452 """Return the hash associated with this target for this widget class | |
453 | |
454 some widget classes can manage several target on the same instance | |
455 (e.g.: a chat widget with multiple resources on the same bare jid), | |
456 this method allow to return a hash associated to one or several targets | |
457 to retrieve the good instance. For example, a widget managing JID targets, | |
458 and all resource of the same bare jid would return the bare jid as hash. | |
459 | |
460 @param target: target to check | |
461 @param profiles: profile(s) associated to target, see __init__ docstring | |
462 @return: a hash (can correspond to one or many targets or profiles, depending of widget class) | |
463 """ | |
464 return str(target) # by defaut, there is one hash for one target | |
465 | |
466 # widget life events | |
467 | |
468 def on_delete(self, *args, **kwargs): | |
469 """Called when a widget is being deleted | |
470 | |
471 @return (boot, None): False to cancel deletion | |
472 all other value continue deletion | |
473 """ | |
474 return True | |
475 | |
476 def on_selected(self): | |
477 """Called when host.selected_widget is this instance""" | |
478 pass |