diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/desktop_kivy/core/common.py	Fri Jun 02 18:26:16 2023 +0200
@@ -0,0 +1,481 @@
+#!/usr/bin/env python3
+
+#Libervia Desktop-Kivy
+# Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""common simple widgets"""
+
+import json
+from functools import partial, total_ordering
+from kivy.uix.widget import Widget
+from kivy.uix.label import Label
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.behaviors import ToggleButtonBehavior
+from kivy.uix.stacklayout import StackLayout
+from kivy.uix.boxlayout import BoxLayout
+from kivy.uix.scrollview import ScrollView
+from kivy.event import EventDispatcher
+from kivy.metrics import dp
+from kivy import properties
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
+from libervia.backend.tools.common import data_format
+from libervia.frontends.quick_frontend import quick_chat
+from .constants import Const as C
+from .common_widgets import CategorySeparator
+from .image import Image, AsyncImage
+from libervia.desktop_kivy import G
+
+log = logging.getLogger(__name__)
+
+UNKNOWN_SYMBOL = 'Unknown symbol name'
+
+
+class IconButton(ButtonBehavior, Image):
+    pass
+
+
+class Avatar(Image):
+    data = properties.DictProperty(allownone=True)
+
+    def on_kv_post(self, __):
+        if not self.source:
+            self.source = G.host.get_default_avatar()
+
+    def on_data(self, __, data):
+        if data is None:
+            self.source = G.host.get_default_avatar()
+        else:
+            self.source = data['path']
+
+
+class NotifLabel(Label):
+    pass
+
+@total_ordering
+class ContactItem(BoxLayout):
+    """An item from ContactList
+
+    The item will drawn as an icon (JID avatar) with its jid below.
+    If "badge_text" is set, a label with the text will be drawn above the avatar.
+    """
+    base_width = dp(150)
+    avatar_layout = properties.ObjectProperty()
+    avatar = properties.ObjectProperty()
+    badge = properties.ObjectProperty(allownone=True)
+    badge_text = properties.StringProperty('')
+    profile = properties.StringProperty()
+    data = properties.DictProperty()
+    jid = properties.StringProperty('')
+
+    def on_kv_post(self, __):
+        if ((self.profile and self.jid and self.data is not None
+             and ('avatar' not in self.data or 'nicknames' not in self.data))):
+            G.host.bridge.identity_get(
+                self.jid, ['avatar', 'nicknames'], True, self.profile,
+                callback=self._identity_get_cb,
+                errback=partial(
+                    G.host.errback,
+                    message=_("Can't retrieve identity for {jid}: {{msg}}").format(
+                        jid=self.jid)
+                )
+            )
+
+    def _identity_get_cb(self, identity_raw):
+        identity_data = data_format.deserialise(identity_raw)
+        self.data.update(identity_data)
+
+    def on_badge_text(self, wid, text):
+        if text:
+            if self.badge is not None:
+                self.badge.text = text
+            else:
+                self.badge = NotifLabel(
+                    pos_hint={"right": 0.8, "y": 0},
+                    text=text,
+                )
+                self.avatar_layout.add_widget(self.badge)
+        else:
+            if self.badge is not None:
+                self.avatar_layout.remove_widget(self.badge)
+                self.badge = None
+
+    def __lt__(self, other):
+        return self.jid < other.jid
+
+
+class ContactButton(ButtonBehavior, ContactItem):
+    pass
+
+
+class JidItem(BoxLayout):
+    bg_color = properties.ListProperty([0.2, 0.2, 0.2, 1])
+    color = properties.ListProperty([1, 1, 1, 1])
+    jid = properties.StringProperty()
+    profile = properties.StringProperty()
+    nick = properties.StringProperty()
+    avatar = properties.ObjectProperty()
+
+    def on_avatar(self, wid, jid_):
+        if self.jid and self.profile:
+            self.get_image()
+
+    def on_jid(self, wid, jid_):
+        if self.profile and self.avatar:
+            self.get_image()
+
+    def on_profile(self, wid, profile):
+        if self.jid and self.avatar:
+            self.get_image()
+
+    def get_image(self):
+        host = G.host
+        if host.contact_lists[self.profile].is_room(self.jid.bare):
+            self.avatar.opacity = 0
+            self.avatar.source = ""
+        else:
+            self.avatar.source = (
+                host.get_avatar(self.jid, profile=self.profile)
+                or host.get_default_avatar(self.jid)
+            )
+
+
+class JidButton(ButtonBehavior, JidItem):
+    pass
+
+
+class JidToggle(ToggleButtonBehavior, JidItem):
+    selected_color = properties.ListProperty(C.COLOR_SEC_DARK)
+
+
+class Symbol(Label):
+    symbol_map = None
+    symbol = properties.StringProperty()
+
+    def __init__(self, **kwargs):
+        if self.symbol_map is None:
+            with open(G.host.app.expand('{media}/fonts/fontello/config.json')) as f:
+                fontello_conf = json.load(f)
+            Symbol.symbol_map = {g['css']:g['code'] for g in fontello_conf['glyphs']}
+
+        super(Symbol, self).__init__(**kwargs)
+
+    def on_symbol(self, instance, symbol):
+        try:
+            code = self.symbol_map[symbol]
+        except KeyError:
+            log.warning(_("Invalid symbol {symbol}").format(symbol=symbol))
+        else:
+            self.text = chr(code)
+
+
+class SymbolButton(ButtonBehavior, Symbol):
+    pass
+
+
+class SymbolLabel(BoxLayout):
+    symbol = properties.StringProperty("")
+    text = properties.StringProperty("")
+    color = properties.ListProperty(C.COLOR_SEC)
+    bold = properties.BooleanProperty(True)
+    symbol_wid = properties.ObjectProperty()
+    label = properties.ObjectProperty()
+
+
+class SymbolButtonLabel(ButtonBehavior, SymbolLabel):
+    pass
+
+
+class SymbolToggleLabel(ToggleButtonBehavior, SymbolLabel):
+    pass
+
+
+class ActionSymbol(Symbol):
+    pass
+
+
+class ActionIcon(BoxLayout):
+    plugin_info = properties.DictProperty()
+
+    def on_plugin_info(self, instance, plugin_info):
+        self.clear_widgets()
+        try:
+            symbol = plugin_info['icon_symbol']
+        except KeyError:
+            icon_src = plugin_info['icon_medium']
+            icon_wid = Image(source=icon_src, allow_stretch=True)
+            self.add_widget(icon_wid)
+        else:
+            icon_wid = ActionSymbol(symbol=symbol)
+            self.add_widget(icon_wid)
+
+
+class SizedImage(AsyncImage):
+    """AsyncImage sized according to C.IMG_MAX_WIDTH and C.IMG_MAX_HEIGHT"""
+    # following properties are desired height/width
+    # i.e. the ones specified in height/width attributes of <img>
+    # (or wanted for whatever reason)
+    # set to None to ignore them
+    target_height = properties.NumericProperty(allownone=True)
+    target_width = properties.NumericProperty(allownone=True)
+
+    def __init__(self, **kwargs):
+        # best calculated size
+        self._best_width = self._best_height = 100
+        super().__init__(**kwargs)
+
+    def on_texture(self, instance, texture):
+        """Adapt the size according to max size and target_*"""
+        if texture is None:
+            return
+        max_width, max_height = dp(C.IMG_MAX_WIDTH), dp(C.IMG_MAX_HEIGHT)
+        width, height = texture.size
+        if self.target_width:
+            width = min(width, self.target_width)
+        if width > max_width:
+            width = C.IMG_MAX_WIDTH
+
+        height = width / self.image_ratio
+
+        if self.target_height:
+            height = min(height, self.target_height)
+
+        if height > max_height:
+            height = max_height
+            width = height * self.image_ratio
+
+        self.width, self.height = self._best_width, self._best_height = width, height
+
+    def on_parent(self, instance, parent):
+        if parent is not None:
+            parent.bind(width=self.on_parent_width)
+
+    def on_parent_width(self, instance, width):
+        if self._best_width > width:
+            self.width = width
+            self.height = width / self.image_ratio
+        else:
+            self.width, self.height = self._best_width, self._best_height
+
+
+class JidSelectorCategoryLayout(StackLayout):
+    pass
+
+
+class JidSelector(ScrollView, EventDispatcher):
+    layout = properties.ObjectProperty(None)
+    # if item_class is changed, the properties must be the same as for ContactButton
+    # and ordering must be supported
+    item_class = properties.ObjectProperty(ContactButton)
+    add_separators = properties.ObjectProperty(True)
+    # list of item to show, can be:
+    #    - a well-known string which can be:
+    #       * "roster": all roster jids
+    #       * "opened_chats": all opened chat widgets
+    #       * "bookmarks": MUC bookmarks
+    #       A layout will be created each time and stored in the attribute of the same
+    #       name.
+    #       If add_separators is True, a CategorySeparator will be added on top of each
+    #       layout.
+    #    - a kivy Widget, which will be added to the layout (notable useful with
+    #      common_widgets.CategorySeparator)
+    #    - a callable, which must return an iterable of kwargs for ContactButton
+    to_show = properties.ListProperty(['roster'])
+
+    # TODO: roster and bookmarks must be updated in real time, like for opened_chats
+
+
+    def __init__(self, **kwargs):
+        self.register_event_type('on_select')
+        # list of layouts containing items
+        self.items_layouts = []
+        # jid to list of ContactButton instances map
+        self.items_map = {}
+        super().__init__(**kwargs)
+
+    def on_kv_post(self, wid):
+        self.update()
+
+    def on_select(self, wid):
+        pass
+
+    def on_parent(self, wid, parent):
+        if parent is None:
+            log.debug("removing listeners")
+            G.host.removeListener("contactsFilled", self.on_contacts_filled)
+            G.host.removeListener("notification", self.on_notification)
+            G.host.removeListener("notificationsClear", self.on_notifications_clear)
+            G.host.removeListener(
+                "widgetNew", self.on_widget_new, ignore_missing=True)
+            G.host.removeListener(
+                "widgetDeleted", self.on_widget_deleted, ignore_missing=True)
+        else:
+            log.debug("adding listeners")
+            G.host.addListener("contactsFilled", self.on_contacts_filled)
+            G.host.addListener("notification", self.on_notification)
+            G.host.addListener("notificationsClear", self.on_notifications_clear)
+
+    def on_contacts_filled(self, profile):
+        log.debug("on_contacts_filled event received")
+        self.update()
+
+    def on_notification(self, entity, notification_data, profile):
+        for item in self.items_map.get(entity.bare, []):
+            notifs = list(G.host.get_notifs(entity.bare, profile=profile))
+            item.badge_text = str(len(notifs))
+
+    def on_notifications_clear(self, entity, type_, profile):
+        for item in self.items_map.get(entity.bare, []):
+            item.badge_text = ''
+
+    def on_widget_new(self, wid):
+        if not isinstance(wid, quick_chat.QuickChat):
+            return
+        item = self.get_item_from_wid(wid)
+        if item is None:
+            return
+        idx = 0
+        for child in self.opened_chats.children:
+            if isinstance(child, self.item_class) and child < item:
+                break
+            idx+=1
+        self.opened_chats.add_widget(item, index=idx)
+
+    def on_widget_deleted(self, wid):
+        if not isinstance(wid, quick_chat.QuickChat):
+            return
+
+        for child in self.opened_chats.children:
+            if not isinstance(child, self.item_class):
+                continue
+            if child.jid.bare == wid.target.bare:
+                self.opened_chats.remove_widget(child)
+                break
+
+    def _create_item(self, **kwargs):
+        item = self.item_class(**kwargs)
+        jid = kwargs['jid']
+        self.items_map.setdefault(jid, []).append(item)
+        return item
+
+    def update(self):
+        log.debug("starting update")
+        self.layout.clear_widgets()
+        for item in self.to_show:
+            if isinstance(item, str):
+                if item == 'roster':
+                    self.add_roster_items()
+                elif item == 'bookmarks':
+                    self.add_bookmarks_items()
+                elif item == 'opened_chats':
+                    self.add_opened_chats_items()
+                else:
+                    log.error(f'unknown "to_show" magic string {item!r}')
+            elif isinstance(item, Widget):
+                self.layout.add_widget(item)
+            elif callable(item):
+                items_kwargs = item()
+                for item_kwargs in items_kwargs:
+                    item = self._create_item(**items_kwargs)
+                    item.bind(on_press=partial(self.dispatch, 'on_select'))
+                    self.layout.add_widget(item)
+            else:
+                log.error(f"unmanaged to_show item type: {item!r}")
+
+    def add_category_layout(self, label=None):
+        category_layout = JidSelectorCategoryLayout()
+
+        if label and self.add_separators:
+            category_layout.add_widget(CategorySeparator(text=label))
+
+        self.layout.add_widget(category_layout)
+        self.items_layouts.append(category_layout)
+        return category_layout
+
+    def get_item_from_wid(self, wid):
+        """create JidSelector item from QuickChat widget"""
+        contact_list = G.host.contact_lists[wid.profile]
+        try:
+            data=contact_list.get_item(wid.target)
+        except KeyError:
+            log.warning(f"Can't find item data for {wid.target}")
+            data={}
+        try:
+            item = self._create_item(
+                jid=wid.target,
+                data=data,
+                profile=wid.profile,
+            )
+        except Exception as e:
+            log.warning(f"Can't add contact {wid.target}: {e}")
+            return
+        notifs = list(G.host.get_notifs(wid.target, profile=wid.profile))
+        if notifs:
+            item.badge_text = str(len(notifs))
+        item.bind(on_press=partial(self.dispatch, 'on_select'))
+        return item
+
+    def add_opened_chats_items(self):
+        G.host.addListener("widgetNew", self.on_widget_new)
+        G.host.addListener("widgetDeleted", self.on_widget_deleted)
+        self.opened_chats = category_layout = self.add_category_layout(_("Opened chats"))
+        widgets = sorted(G.host.widgets.get_widgets(
+            quick_chat.QuickChat,
+            profiles = G.host.profiles,
+            with_duplicates=False))
+
+        for wid in widgets:
+            item = self.get_item_from_wid(wid)
+            if item is None:
+                continue
+            category_layout.add_widget(item)
+
+    def add_roster_items(self):
+        self.roster = category_layout = self.add_category_layout(_("Your contacts"))
+        for profile in G.host.profiles:
+            contact_list = G.host.contact_lists[profile]
+            for entity_jid in sorted(contact_list.roster):
+                item = self._create_item(
+                    jid=entity_jid,
+                    data=contact_list.get_item(entity_jid),
+                    profile=profile,
+                )
+                item.bind(on_press=partial(self.dispatch, 'on_select'))
+                category_layout.add_widget(item)
+
+    def add_bookmarks_items(self):
+        self.bookmarks = category_layout = self.add_category_layout(_("Your chat rooms"))
+        for profile in G.host.profiles:
+            profile_manager = G.host.profiles[profile]
+            try:
+                bookmarks = profile_manager._bookmarks
+            except AttributeError:
+                log.warning(f"no bookmark in cache for profile {profile}")
+                continue
+
+            contact_list = G.host.contact_lists[profile]
+            for entity_jid in bookmarks:
+                try:
+                    cache = contact_list.get_item(entity_jid)
+                except KeyError:
+                    cache = {}
+                item = self._create_item(
+                    jid=entity_jid,
+                    data=cache,
+                    profile=profile,
+                )
+                item.bind(on_press=partial(self.dispatch, 'on_select'))
+                category_layout.add_widget(item)