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