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)