Mercurial > libervia-desktop-kivy
comparison libervia/desktop_kivy/core/common.py @ 493:b3cedbee561d
refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 18:26:16 +0200 |
parents | cagou/core/common.py@203755bbe0fe |
children | d1a023280733 |
comparison
equal
deleted
inserted
replaced
492:5114bbb5daa3 | 493:b3cedbee561d |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 #Libervia Desktop-Kivy | |
4 # Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 """common simple widgets""" | |
20 | |
21 import json | |
22 from functools import partial, total_ordering | |
23 from kivy.uix.widget import Widget | |
24 from kivy.uix.label import Label | |
25 from kivy.uix.behaviors import ButtonBehavior | |
26 from kivy.uix.behaviors import ToggleButtonBehavior | |
27 from kivy.uix.stacklayout import StackLayout | |
28 from kivy.uix.boxlayout import BoxLayout | |
29 from kivy.uix.scrollview import ScrollView | |
30 from kivy.event import EventDispatcher | |
31 from kivy.metrics import dp | |
32 from kivy import properties | |
33 from libervia.backend.core.i18n import _ | |
34 from libervia.backend.core import log as logging | |
35 from libervia.backend.tools.common import data_format | |
36 from libervia.frontends.quick_frontend import quick_chat | |
37 from .constants import Const as C | |
38 from .common_widgets import CategorySeparator | |
39 from .image import Image, AsyncImage | |
40 from libervia.desktop_kivy import G | |
41 | |
42 log = logging.getLogger(__name__) | |
43 | |
44 UNKNOWN_SYMBOL = 'Unknown symbol name' | |
45 | |
46 | |
47 class IconButton(ButtonBehavior, Image): | |
48 pass | |
49 | |
50 | |
51 class Avatar(Image): | |
52 data = properties.DictProperty(allownone=True) | |
53 | |
54 def on_kv_post(self, __): | |
55 if not self.source: | |
56 self.source = G.host.get_default_avatar() | |
57 | |
58 def on_data(self, __, data): | |
59 if data is None: | |
60 self.source = G.host.get_default_avatar() | |
61 else: | |
62 self.source = data['path'] | |
63 | |
64 | |
65 class NotifLabel(Label): | |
66 pass | |
67 | |
68 @total_ordering | |
69 class ContactItem(BoxLayout): | |
70 """An item from ContactList | |
71 | |
72 The item will drawn as an icon (JID avatar) with its jid below. | |
73 If "badge_text" is set, a label with the text will be drawn above the avatar. | |
74 """ | |
75 base_width = dp(150) | |
76 avatar_layout = properties.ObjectProperty() | |
77 avatar = properties.ObjectProperty() | |
78 badge = properties.ObjectProperty(allownone=True) | |
79 badge_text = properties.StringProperty('') | |
80 profile = properties.StringProperty() | |
81 data = properties.DictProperty() | |
82 jid = properties.StringProperty('') | |
83 | |
84 def on_kv_post(self, __): | |
85 if ((self.profile and self.jid and self.data is not None | |
86 and ('avatar' not in self.data or 'nicknames' not in self.data))): | |
87 G.host.bridge.identity_get( | |
88 self.jid, ['avatar', 'nicknames'], True, self.profile, | |
89 callback=self._identity_get_cb, | |
90 errback=partial( | |
91 G.host.errback, | |
92 message=_("Can't retrieve identity for {jid}: {{msg}}").format( | |
93 jid=self.jid) | |
94 ) | |
95 ) | |
96 | |
97 def _identity_get_cb(self, identity_raw): | |
98 identity_data = data_format.deserialise(identity_raw) | |
99 self.data.update(identity_data) | |
100 | |
101 def on_badge_text(self, wid, text): | |
102 if text: | |
103 if self.badge is not None: | |
104 self.badge.text = text | |
105 else: | |
106 self.badge = NotifLabel( | |
107 pos_hint={"right": 0.8, "y": 0}, | |
108 text=text, | |
109 ) | |
110 self.avatar_layout.add_widget(self.badge) | |
111 else: | |
112 if self.badge is not None: | |
113 self.avatar_layout.remove_widget(self.badge) | |
114 self.badge = None | |
115 | |
116 def __lt__(self, other): | |
117 return self.jid < other.jid | |
118 | |
119 | |
120 class ContactButton(ButtonBehavior, ContactItem): | |
121 pass | |
122 | |
123 | |
124 class JidItem(BoxLayout): | |
125 bg_color = properties.ListProperty([0.2, 0.2, 0.2, 1]) | |
126 color = properties.ListProperty([1, 1, 1, 1]) | |
127 jid = properties.StringProperty() | |
128 profile = properties.StringProperty() | |
129 nick = properties.StringProperty() | |
130 avatar = properties.ObjectProperty() | |
131 | |
132 def on_avatar(self, wid, jid_): | |
133 if self.jid and self.profile: | |
134 self.get_image() | |
135 | |
136 def on_jid(self, wid, jid_): | |
137 if self.profile and self.avatar: | |
138 self.get_image() | |
139 | |
140 def on_profile(self, wid, profile): | |
141 if self.jid and self.avatar: | |
142 self.get_image() | |
143 | |
144 def get_image(self): | |
145 host = G.host | |
146 if host.contact_lists[self.profile].is_room(self.jid.bare): | |
147 self.avatar.opacity = 0 | |
148 self.avatar.source = "" | |
149 else: | |
150 self.avatar.source = ( | |
151 host.get_avatar(self.jid, profile=self.profile) | |
152 or host.get_default_avatar(self.jid) | |
153 ) | |
154 | |
155 | |
156 class JidButton(ButtonBehavior, JidItem): | |
157 pass | |
158 | |
159 | |
160 class JidToggle(ToggleButtonBehavior, JidItem): | |
161 selected_color = properties.ListProperty(C.COLOR_SEC_DARK) | |
162 | |
163 | |
164 class Symbol(Label): | |
165 symbol_map = None | |
166 symbol = properties.StringProperty() | |
167 | |
168 def __init__(self, **kwargs): | |
169 if self.symbol_map is None: | |
170 with open(G.host.app.expand('{media}/fonts/fontello/config.json')) as f: | |
171 fontello_conf = json.load(f) | |
172 Symbol.symbol_map = {g['css']:g['code'] for g in fontello_conf['glyphs']} | |
173 | |
174 super(Symbol, self).__init__(**kwargs) | |
175 | |
176 def on_symbol(self, instance, symbol): | |
177 try: | |
178 code = self.symbol_map[symbol] | |
179 except KeyError: | |
180 log.warning(_("Invalid symbol {symbol}").format(symbol=symbol)) | |
181 else: | |
182 self.text = chr(code) | |
183 | |
184 | |
185 class SymbolButton(ButtonBehavior, Symbol): | |
186 pass | |
187 | |
188 | |
189 class SymbolLabel(BoxLayout): | |
190 symbol = properties.StringProperty("") | |
191 text = properties.StringProperty("") | |
192 color = properties.ListProperty(C.COLOR_SEC) | |
193 bold = properties.BooleanProperty(True) | |
194 symbol_wid = properties.ObjectProperty() | |
195 label = properties.ObjectProperty() | |
196 | |
197 | |
198 class SymbolButtonLabel(ButtonBehavior, SymbolLabel): | |
199 pass | |
200 | |
201 | |
202 class SymbolToggleLabel(ToggleButtonBehavior, SymbolLabel): | |
203 pass | |
204 | |
205 | |
206 class ActionSymbol(Symbol): | |
207 pass | |
208 | |
209 | |
210 class ActionIcon(BoxLayout): | |
211 plugin_info = properties.DictProperty() | |
212 | |
213 def on_plugin_info(self, instance, plugin_info): | |
214 self.clear_widgets() | |
215 try: | |
216 symbol = plugin_info['icon_symbol'] | |
217 except KeyError: | |
218 icon_src = plugin_info['icon_medium'] | |
219 icon_wid = Image(source=icon_src, allow_stretch=True) | |
220 self.add_widget(icon_wid) | |
221 else: | |
222 icon_wid = ActionSymbol(symbol=symbol) | |
223 self.add_widget(icon_wid) | |
224 | |
225 | |
226 class SizedImage(AsyncImage): | |
227 """AsyncImage sized according to C.IMG_MAX_WIDTH and C.IMG_MAX_HEIGHT""" | |
228 # following properties are desired height/width | |
229 # i.e. the ones specified in height/width attributes of <img> | |
230 # (or wanted for whatever reason) | |
231 # set to None to ignore them | |
232 target_height = properties.NumericProperty(allownone=True) | |
233 target_width = properties.NumericProperty(allownone=True) | |
234 | |
235 def __init__(self, **kwargs): | |
236 # best calculated size | |
237 self._best_width = self._best_height = 100 | |
238 super().__init__(**kwargs) | |
239 | |
240 def on_texture(self, instance, texture): | |
241 """Adapt the size according to max size and target_*""" | |
242 if texture is None: | |
243 return | |
244 max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT) | |
245 width, height = texture.size | |
246 if self.target_width: | |
247 width = min(width, self.target_width) | |
248 if width > max_width: | |
249 width = C.IMG_MAX_WIDTH | |
250 | |
251 height = width / self.image_ratio | |
252 | |
253 if self.target_height: | |
254 height = min(height, self.target_height) | |
255 | |
256 if height > max_height: | |
257 height = max_height | |
258 width = height * self.image_ratio | |
259 | |
260 self.width, self.height = self._best_width, self._best_height = width, height | |
261 | |
262 def on_parent(self, instance, parent): | |
263 if parent is not None: | |
264 parent.bind(width=self.on_parent_width) | |
265 | |
266 def on_parent_width(self, instance, width): | |
267 if self._best_width > width: | |
268 self.width = width | |
269 self.height = width / self.image_ratio | |
270 else: | |
271 self.width, self.height = self._best_width, self._best_height | |
272 | |
273 | |
274 class JidSelectorCategoryLayout(StackLayout): | |
275 pass | |
276 | |
277 | |
278 class JidSelector(ScrollView, EventDispatcher): | |
279 layout = properties.ObjectProperty(None) | |
280 # if item_class is changed, the properties must be the same as for ContactButton | |
281 # and ordering must be supported | |
282 item_class = properties.ObjectProperty(ContactButton) | |
283 add_separators = properties.ObjectProperty(True) | |
284 # list of item to show, can be: | |
285 # - a well-known string which can be: | |
286 # * "roster": all roster jids | |
287 # * "opened_chats": all opened chat widgets | |
288 # * "bookmarks": MUC bookmarks | |
289 # A layout will be created each time and stored in the attribute of the same | |
290 # name. | |
291 # If add_separators is True, a CategorySeparator will be added on top of each | |
292 # layout. | |
293 # - a kivy Widget, which will be added to the layout (notable useful with | |
294 # common_widgets.CategorySeparator) | |
295 # - a callable, which must return an iterable of kwargs for ContactButton | |
296 to_show = properties.ListProperty(['roster']) | |
297 | |
298 # TODO: roster and bookmarks must be updated in real time, like for opened_chats | |
299 | |
300 | |
301 def __init__(self, **kwargs): | |
302 self.register_event_type('on_select') | |
303 # list of layouts containing items | |
304 self.items_layouts = [] | |
305 # jid to list of ContactButton instances map | |
306 self.items_map = {} | |
307 super().__init__(**kwargs) | |
308 | |
309 def on_kv_post(self, wid): | |
310 self.update() | |
311 | |
312 def on_select(self, wid): | |
313 pass | |
314 | |
315 def on_parent(self, wid, parent): | |
316 if parent is None: | |
317 log.debug("removing listeners") | |
318 G.host.removeListener("contactsFilled", self.on_contacts_filled) | |
319 G.host.removeListener("notification", self.on_notification) | |
320 G.host.removeListener("notificationsClear", self.on_notifications_clear) | |
321 G.host.removeListener( | |
322 "widgetNew", self.on_widget_new, ignore_missing=True) | |
323 G.host.removeListener( | |
324 "widgetDeleted", self.on_widget_deleted, ignore_missing=True) | |
325 else: | |
326 log.debug("adding listeners") | |
327 G.host.addListener("contactsFilled", self.on_contacts_filled) | |
328 G.host.addListener("notification", self.on_notification) | |
329 G.host.addListener("notificationsClear", self.on_notifications_clear) | |
330 | |
331 def on_contacts_filled(self, profile): | |
332 log.debug("on_contacts_filled event received") | |
333 self.update() | |
334 | |
335 def on_notification(self, entity, notification_data, profile): | |
336 for item in self.items_map.get(entity.bare, []): | |
337 notifs = list(G.host.get_notifs(entity.bare, profile=profile)) | |
338 item.badge_text = str(len(notifs)) | |
339 | |
340 def on_notifications_clear(self, entity, type_, profile): | |
341 for item in self.items_map.get(entity.bare, []): | |
342 item.badge_text = '' | |
343 | |
344 def on_widget_new(self, wid): | |
345 if not isinstance(wid, quick_chat.QuickChat): | |
346 return | |
347 item = self.get_item_from_wid(wid) | |
348 if item is None: | |
349 return | |
350 idx = 0 | |
351 for child in self.opened_chats.children: | |
352 if isinstance(child, self.item_class) and child < item: | |
353 break | |
354 idx+=1 | |
355 self.opened_chats.add_widget(item, index=idx) | |
356 | |
357 def on_widget_deleted(self, wid): | |
358 if not isinstance(wid, quick_chat.QuickChat): | |
359 return | |
360 | |
361 for child in self.opened_chats.children: | |
362 if not isinstance(child, self.item_class): | |
363 continue | |
364 if child.jid.bare == wid.target.bare: | |
365 self.opened_chats.remove_widget(child) | |
366 break | |
367 | |
368 def _create_item(self, **kwargs): | |
369 item = self.item_class(**kwargs) | |
370 jid = kwargs['jid'] | |
371 self.items_map.setdefault(jid, []).append(item) | |
372 return item | |
373 | |
374 def update(self): | |
375 log.debug("starting update") | |
376 self.layout.clear_widgets() | |
377 for item in self.to_show: | |
378 if isinstance(item, str): | |
379 if item == 'roster': | |
380 self.add_roster_items() | |
381 elif item == 'bookmarks': | |
382 self.add_bookmarks_items() | |
383 elif item == 'opened_chats': | |
384 self.add_opened_chats_items() | |
385 else: | |
386 log.error(f'unknown "to_show" magic string {item!r}') | |
387 elif isinstance(item, Widget): | |
388 self.layout.add_widget(item) | |
389 elif callable(item): | |
390 items_kwargs = item() | |
391 for item_kwargs in items_kwargs: | |
392 item = self._create_item(**items_kwargs) | |
393 item.bind(on_press=partial(self.dispatch, 'on_select')) | |
394 self.layout.add_widget(item) | |
395 else: | |
396 log.error(f"unmanaged to_show item type: {item!r}") | |
397 | |
398 def add_category_layout(self, label=None): | |
399 category_layout = JidSelectorCategoryLayout() | |
400 | |
401 if label and self.add_separators: | |
402 category_layout.add_widget(CategorySeparator(text=label)) | |
403 | |
404 self.layout.add_widget(category_layout) | |
405 self.items_layouts.append(category_layout) | |
406 return category_layout | |
407 | |
408 def get_item_from_wid(self, wid): | |
409 """create JidSelector item from QuickChat widget""" | |
410 contact_list = G.host.contact_lists[wid.profile] | |
411 try: | |
412 data=contact_list.get_item(wid.target) | |
413 except KeyError: | |
414 log.warning(f"Can't find item data for {wid.target}") | |
415 data={} | |
416 try: | |
417 item = self._create_item( | |
418 jid=wid.target, | |
419 data=data, | |
420 profile=wid.profile, | |
421 ) | |
422 except Exception as e: | |
423 log.warning(f"Can't add contact {wid.target}: {e}") | |
424 return | |
425 notifs = list(G.host.get_notifs(wid.target, profile=wid.profile)) | |
426 if notifs: | |
427 item.badge_text = str(len(notifs)) | |
428 item.bind(on_press=partial(self.dispatch, 'on_select')) | |
429 return item | |
430 | |
431 def add_opened_chats_items(self): | |
432 G.host.addListener("widgetNew", self.on_widget_new) | |
433 G.host.addListener("widgetDeleted", self.on_widget_deleted) | |
434 self.opened_chats = category_layout = self.add_category_layout(_("Opened chats")) | |
435 widgets = sorted(G.host.widgets.get_widgets( | |
436 quick_chat.QuickChat, | |
437 profiles = G.host.profiles, | |
438 with_duplicates=False)) | |
439 | |
440 for wid in widgets: | |
441 item = self.get_item_from_wid(wid) | |
442 if item is None: | |
443 continue | |
444 category_layout.add_widget(item) | |
445 | |
446 def add_roster_items(self): | |
447 self.roster = category_layout = self.add_category_layout(_("Your contacts")) | |
448 for profile in G.host.profiles: | |
449 contact_list = G.host.contact_lists[profile] | |
450 for entity_jid in sorted(contact_list.roster): | |
451 item = self._create_item( | |
452 jid=entity_jid, | |
453 data=contact_list.get_item(entity_jid), | |
454 profile=profile, | |
455 ) | |
456 item.bind(on_press=partial(self.dispatch, 'on_select')) | |
457 category_layout.add_widget(item) | |
458 | |
459 def add_bookmarks_items(self): | |
460 self.bookmarks = category_layout = self.add_category_layout(_("Your chat rooms")) | |
461 for profile in G.host.profiles: | |
462 profile_manager = G.host.profiles[profile] | |
463 try: | |
464 bookmarks = profile_manager._bookmarks | |
465 except AttributeError: | |
466 log.warning(f"no bookmark in cache for profile {profile}") | |
467 continue | |
468 | |
469 contact_list = G.host.contact_lists[profile] | |
470 for entity_jid in bookmarks: | |
471 try: | |
472 cache = contact_list.get_item(entity_jid) | |
473 except KeyError: | |
474 cache = {} | |
475 item = self._create_item( | |
476 jid=entity_jid, | |
477 data=cache, | |
478 profile=profile, | |
479 ) | |
480 item.bind(on_press=partial(self.dispatch, 'on_select')) | |
481 category_layout.add_widget(item) |