changeset 353:19422bbd9c8e

core (widgets handler): refactoring: - CagouWidget now has class properties (to be overridden when needed) which indicate how if the widget handle must add a wrapping ScreenManager (global_screen_manager) or show all instances of the class in a Carousel (collection_carousel). If none of those options is used, a ScrollView will be wrapping the widget, to be sure that the widget will be resized correctly when necessary (without it, the widget could still be drawn in the backround when the size is too small and overflow on the WidgetWrapper, this would be the case with WidgetSelector) - some helper methods/properties have been added to CagouWidget. Check docstrings for details - better handling of (in)visible widget in WidgetsHandler - thanks to the new wrapping ScrollView, WidgetSelect will show scroll bars if the available space is too small. - bugs fixes
author Goffi <goffi@goffi.org>
date Fri, 17 Jan 2020 18:44:35 +0100
parents 434f770fe55b
children aa860c10acfc
files cagou/core/cagou_main.py cagou/core/cagou_widget.py cagou/core/widgets_handler.py cagou/kv/widgets_handler.kv cagou/plugins/plugin_wid_chat.py cagou/plugins/plugin_wid_widget_selector.kv cagou/plugins/plugin_wid_widget_selector.py
diffstat 7 files changed, 289 insertions(+), 89 deletions(-) [+]
line wrap: on
line diff
--- a/cagou/core/cagou_main.py	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/core/cagou_main.py	Fri Jan 17 18:44:35 2020 +0100
@@ -753,14 +753,7 @@
             raise exceptions.InternalError("no CagouWidget found when "
                                            "trying to switch widget")
 
-        wrapper = to_change.parent
-        while wrapper is not None and not(isinstance(wrapper, widgets_handler.WHWrapper)):
-            wrapper = wrapper.parent
-
-        if wrapper is None:
-            raise exceptions.InternalError("no wrapper found")
-
-        wrapper.changeWidget(new)
+        to_change.whwrapper.changeWidget(new)
         self.selected_widget = new
 
     def _addVisibleWidget(self, widget):
@@ -811,31 +804,35 @@
             return set()
 
     def deleteUnusedWidgetInstances(self, widget):
-        """Delete instance of this widget without parent
+        """Delete instance of this widget which are not attached to a WHWrapper
 
         @param widget(quick_widgets.QuickWidget): reference widget
             other instance of this widget will be deleted if they have no parent
         """
-        # FIXME: unused for now
         to_delete = []
         if isinstance(widget, quick_widgets.QuickWidget):
             for w in self.widgets.getWidgetInstances(widget):
-                if w.parent is None and w != widget:
+                if w.whwrapper is None and w != widget:
                     to_delete.append(w)
             for w in to_delete:
                 log.debug("cleaning widget: {wid}".format(wid=w))
                 self.widgets.deleteWidget(w)
 
-    def getOrClone(self, widget):
-        """Get a QuickWidget if it has not parent set else clone it
+    def getOrClone(self, widget, **kwargs):
+        """Get a QuickWidget if it is not in a WHWrapper, else clone it
 
-        if an other instance of this widget exist without parent, it will be used.
+        if an other instance of this widget exist without being in a WHWrapper
+        (i.e. if it is not already in use) it will be used.
         """
-        if widget.parent is None:
+        if widget.whwrapper is None:
+            if widget.parent is not None:
+                widget.parent.remove_widget(widget)
             self.deleteUnusedWidgetInstances(widget)
             return widget
         for w in self.widgets.getWidgetInstances(widget):
-            if w.parent is None:
+            if w.whwrapper is None:
+                if w.parent is not None:
+                    w.parent.remove_widget(w)
                 self.deleteUnusedWidgetInstances(w)
                 return w
         targets = list(widget.targets)
@@ -843,7 +840,8 @@
                                            targets[0],
                                            on_new_widget=None,
                                            on_existing_widget=C.WIDGET_RECREATE,
-                                           profiles=widget.profiles)
+                                           profiles=widget.profiles,
+                                           **kwargs)
         for t in targets[1:]:
             w.addTarget(t)
         return w
@@ -927,7 +925,8 @@
     ## misc ##
 
     def plugging_profiles(self):
-        self.app.root.changeWidget(widgets_handler.WidgetsHandler())
+        self._widgets_handler = widgets_handler.WidgetsHandler()
+        self.app.root.changeWidget(self._widgets_handler)
         self.bridge.menusGet("", C.NO_SECURITY_LIMIT, callback=self._menusGetCb)
 
     def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE):
@@ -1023,6 +1022,23 @@
                     msg = e))
                 notification = None
 
+    def getParentWHWrapper(self, wid):
+        """Retrieve parent WHWrapper instance managing a widget
+
+        @param wid(Widget): widget to check
+        @return (WHWrapper, None): found instance if any, else None
+        """
+        wh = self.getAncestorWidget(wid, widgets_handler.WHWrapper)
+        if wh is None:
+            # we may have a screen
+            try:
+                sm = wid.screen_manager
+            except (exceptions.InternalError, exceptions.NotFound):
+                return None
+            else:
+                wh = self.getAncestorWidget(sm, widgets_handler.WHWrapper)
+        return wh
+
     def getAncestorWidget(self, wid, cls):
         """Retrieve an ancestor of given class
 
--- a/cagou/core/cagou_widget.py	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/core/cagou_widget.py	Fri Jan 17 18:44:35 2020 +0100
@@ -19,13 +19,17 @@
 
 
 from sat.core import log as logging
-log = logging.getLogger(__name__)
+from sat.core import exceptions
 from kivy.uix.behaviors import ButtonBehavior
 from kivy.uix.boxlayout import BoxLayout
 from kivy.uix.dropdown import DropDown
+from kivy.uix.screenmanager import Screen
 from kivy import properties
 from cagou import G
-from cagou.core.common import ActionIcon
+from .common import ActionIcon
+
+
+log = logging.getLogger(__name__)
 
 
 class HeaderWidgetChoice(ButtonBehavior, BoxLayout):
@@ -44,7 +48,8 @@
 
     def __init__(self, cagou_widget):
         super(HeaderWidgetSelector, self).__init__()
-        for plugin_info in G.host.getPluggedWidgets(except_cls=cagou_widget.__class__):
+        plg_info_cls = cagou_widget.plugin_info_class or cagou_widget.__class__
+        for plugin_info in G.host.getPluggedWidgets(except_cls=plg_info_cls):
             choice = HeaderWidgetChoice(cagou_widget, plugin_info)
             self.add_widget(choice)
 
@@ -58,17 +63,60 @@
 
 
 class CagouWidget(BoxLayout):
+    main_container = properties.ObjectProperty(None)
     header_input = properties.ObjectProperty(None)
     header_box = properties.ObjectProperty(None)
+    # set to True if you want to be able to switch between visible widgets of this
+    # class using a carousel
+    collection_carousel = False
+    # set to True if you a global ScreenManager global to all widgets of this class.
+    # The screen manager is created in WHWrapper
+    global_screen_manager = False
+    # override this if a specific class (i.e. not self.__class__) must be used for
+    # plugin info. Useful when a CagouWidget is used with global_screen_manager.
+    plugin_info_class = None
 
-    def __init__(self):
+    def __init__(self, **kwargs):
+        plg_info_cls = self.plugin_info_class or self.__class__
         for p in G.host.getPluggedWidgets():
-            if p['main'] == self.__class__:
+            if p['main'] == plg_info_cls:
                 self.plugin_info = p
                 break
-        BoxLayout.__init__(self)
+        super().__init__(**kwargs)
         self.selector = HeaderWidgetSelector(self)
 
+    @property
+    def screen_manager(self):
+        if ((not self.global_screen_manager
+             and not (self.plugin_info_class is not None
+                      and self.plugin_info_class.global_screen_manager))):
+            raise exceptions.InternalError(
+                "screen_manager property can't be used if global_screen_manager is not "
+                "set")
+        screen = self.getAncestor(Screen)
+        if screen is None:
+            raise exceptions.NotFound("Can't find parent Screen")
+        if screen.manager is None:
+            raise exceptions.NotFound("Can't find parent ScreenManager")
+        return screen.manager
+
+    @property
+    def whwrapper(self):
+        """Retrieve parent widget handler"""
+        return G.host.getParentWHWrapper(self)
+
+    def screenManagerInit(self, screen_manager):
+        """Override this method to do init when ScreenManager is instantiated
+
+        This is only called once even if collection_carousel is used.
+        """
+        if not self.global_screen_manager:
+            raise exceptions.InternalError("screenManagerInit should not be called")
+
+    def getAncestor(self, cls):
+        """Helper method to use host.getAncestorWidget with self"""
+        return G.host.getAncestorWidget(self, cls)
+
     def switchWidget(self, plugin_info):
         self.selector.dismiss()
         factory = plugin_info["factory"]
--- a/cagou/core/widgets_handler.py	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/core/widgets_handler.py	Fri Jan 17 18:44:35 2020 +0100
@@ -1,5 +1,4 @@
-#!/usr/bin/python
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
 
 # Cagou: desktop/mobile frontend for Salut à Toi XMPP client
 # Copyright (C) 2016-2019 Jérôme Poisson (goffi@goffi.org)
@@ -19,17 +18,22 @@
 
 
 from sat.core import log as logging
-log = logging.getLogger(__name__)
 from sat.core import exceptions
 from sat_frontends.quick_frontend import quick_widgets
 from kivy.graphics import Color, Ellipse
 from kivy.uix.layout import Layout
 from kivy.uix.boxlayout import BoxLayout
 from kivy.uix.stencilview import StencilView
+from kivy.uix.carousel import Carousel
+from kivy.uix.screenmanager import ScreenManager, Screen
+from kivy.uix.scrollview import ScrollView
 from kivy.metrics import dp
 from kivy import properties
 from cagou import G
-from cagou.core.constants import Const as C
+from .constants import Const as C
+from . import cagou_widget
+
+log = logging.getLogger(__name__)
 
 
 REMOVE_WID_LIMIT = dp(50)
@@ -37,7 +41,9 @@
 
 
 class WHWrapper(BoxLayout):
-    carousel = properties.ObjectProperty(None)
+    main_container = properties.ObjectProperty(None)
+    screen_manager = properties.ObjectProperty(None, allownone=True)
+    carousel = properties.ObjectProperty(None, allownone=True)
     split_size = properties.NumericProperty(dp(1))
     split_margin = properties.NumericProperty(dp(2))
     split_color = properties.ListProperty([0.8, 0.8, 0.8, 1])
@@ -54,13 +60,14 @@
         idx = kwargs.pop('_wid_idx')
         self._wid_idx = idx
         super(WHWrapper, self).__init__(**kwargs)
-        self._former_slide = None
-        self.carousel.bind(current_slide=self.onSlideChange)
-        self._slides_update_lock = False
         self._left_wids = set()
         self._top_wids = set()
         self._right_wids = set()
         self._bottom_wids = set()
+        self._clear_attributes()
+
+    def _clear_attributes(self):
+        self._former_slide = None
 
     def __repr__(self):
         return "WHWrapper_{idx}".format(idx=self._wid_idx)
@@ -98,7 +105,46 @@
 
     @property
     def current_slide(self):
-        return self.carousel.current_slide
+        if (self.carousel is not None
+            and (self.screen_manager is None or self.screen_manager.current == '')):
+            return self.carousel.current_slide
+        elif self.screen_manager is not None:
+            # we should have exactly one children in current_screen, else there is a bug
+            return self.screen_manager.current_screen.children[0]
+        else:
+            try:
+                return self.main_container.children[0]
+            except IndexError:
+                log.error("No child found, this should not happen")
+                return None
+
+    @property
+    def carousel_active(self):
+        """Return True if Carousel is used and active"""
+        if self.carousel is None:
+            return False
+        if self.screen_manager is not None and self.screen_manager.current != '':
+            return False
+        return True
+
+    @property
+    def former_screen_wid(self):
+        """Return widget currently active for former screen"""
+        if self.screen_manager is None:
+            raise exceptions.InternalError(
+                "former_screen_wid can only be used if ScreenManager is used")
+        if self._former_screen_name is None:
+            return None
+        return self.getScreenWidget(self._former_screen_name)
+
+    def getScreenWidget(self, screen_name):
+        """Return screen main widget, handling carousel if necessary"""
+        if self.carousel is not None and screen_name == '':
+            return self.carousel.current_slide
+        try:
+            return self.screen_manager.get_screen(screen_name).children[0]
+        except IndexError:
+            return None
 
     def _draw_ellipse(self):
         """draw split ellipse"""
@@ -158,8 +204,8 @@
             touch.ud['ori_width'] = self.width
             self._draw_ellipse()
         else:
-            if len(self.carousel.slides) == 1:
-                # we don't want swipe if there is only one slide
+            if self.carousel_active and len(self.carousel.slides) <= 1:
+                # we don't want swipe of carousel if there is only one slide
                 return StencilView.on_touch_down(self.carousel, touch)
             else:
                 return super(WHWrapper, self).on_touch_down(touch)
@@ -302,50 +348,143 @@
         self.canvas.after.remove(self.ellipse)
         del self.ellipse
 
+    def clear_widgets(self):
+        current_slide = self.current_slide
+        if current_slide is not None:
+            G.host._removeVisibleWidget(current_slide)
+
+        super().clear_widgets()
+
+        self.screen_manager = None
+        self.carousel = None
+        self._clear_attributes()
+
     def set_widget(self, wid, index=0):
-        self.carousel.add_widget(wid, index)
+        assert len(self.children) == 0
+
+        if wid.collection_carousel or wid.global_screen_manager:
+            self.main_container = self
+        else:
+            self.main_container = ScrollView()
+            self.add_widget(self.main_container)
+
+        if self.carousel is not None:
+            return self.carousel.add_widget(wid, index)
+
+        if wid.global_screen_manager:
+            if self.screen_manager is None:
+                self.screen_manager = ScreenManager()
+                self.main_container.add_widget(self.screen_manager)
+                parent = Screen()
+                self.screen_manager.add_widget(parent)
+                self._former_screen_name = ''
+                self.screen_manager.bind(current=self.onScreenChange)
+                wid.screenManagerInit(self.screen_manager)
+        else:
+            parent = self.main_container
+
+        if wid.collection_carousel:
+            # a Carousel is requested, and this is the first widget that we add
+            # so we need to create the carousel
+            self.carousel = Carousel(
+                direction = "right",
+                ignore_perpendicular_swipes = True,
+                loop = True,
+            )
+            self._slides_update_lock = 0
+            self.carousel.bind(current_slide=self.onSlideChange)
+            parent.add_widget(self.carousel)
+            self.carousel.add_widget(wid, index)
+        else:
+            # no Carousel requested, we add the widget as a direct child
+            parent.add_widget(wid)
+            G.host._addVisibleWidget(wid)
 
     def changeWidget(self, new_widget):
         """Change currently displayed widget
 
         slides widgets will be updated
         """
-        # slides update need to be blocked to avoid the update in onSlideChange
-        # which would mess the removal of current widgets
-        self._slides_update_lock = True
-        current = self.carousel.current_slide
-        for w in self.carousel.slides:
-            if w == current or w == new_widget:
-                continue
-            if isinstance(w, quick_widgets.QuickWidget):
-                G.host.widgets.deleteWidget(w)
-        self.carousel.clear_widgets()
-        self.carousel.add_widget(G.host.getOrClone(new_widget))
-        self._slides_update_lock = False
-        self.updateHiddenSlides()
+        if (self.carousel is not None
+            and self.carousel.current_slide.__class__ == new_widget.__class__):
+            # we have the same class, we reuse carousel and screen manager setting
+
+            if self.carousel.current_slide != new_widget:
+                # slides update need to be blocked to avoid the update in onSlideChange
+                # which would mess the removal of current widgets
+                self._slides_update_lock += 1
+                new_wid = None
+                for w in self.carousel.slides[:]:
+                    if w.widget_hash == new_widget.widget_hash:
+                        new_wid = w
+                        continue
+                    self.carousel.remove_widget(w)
+                    if isinstance(w, quick_widgets.QuickWidget):
+                        G.host.widgets.deleteWidget(w)
+                if new_wid is None:
+                    new_wid = G.host.getOrClone(new_widget)
+                    self.carousel.add_widget(new_wid)
+                self._updateHiddenSlides()
+                self._slides_update_lock -= 1
+
+            if self.screen_manager is not None:
+                self.screen_manager.clear_widgets([
+                    s for s in self.screen_manager.screens if s.name != ''])
+                new_wid.screenManagerInit(self.screen_manager)
+        else:
+            # else, we restart fresh
+            self.clear_widgets()
+            self.set_widget(G.host.getOrClone(new_widget))
+
+    def onScreenChange(self, screen_manager, new_screen):
+        try:
+            new_screen_wid = self.current_slide
+        except IndexError:
+            new_screen_wid = None
+            log.warning("Switching to a screen without children")
+        if new_screen == '' and self.carousel is not None:
+            # carousel may have been changed in the background, so we update slides
+            self._updateHiddenSlides()
+        former_screen_wid = self.former_screen_wid
+        if isinstance(former_screen_wid, cagou_widget.CagouWidget):
+            G.host._removeVisibleWidget(former_screen_wid)
+        if isinstance(new_screen_wid, cagou_widget.CagouWidget):
+            G.host._addVisibleWidget(new_screen_wid)
+        self._former_screen_name = new_screen
+        G.host.selected_widget = new_screen_wid
 
     def onSlideChange(self, handler, new_slide):
+        if self._former_slide is new_slide:
+            # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide
+            #        binding now calls onSlideChange twice with the same widget (here
+            #        "new_slide"). To be checked with Kivy team.
+            return
+        log.debug(f"Slide change: new_slide = {new_slide}")
         if self._former_slide is not None:
-            if self._former_slide is new_slide:
-                # FIXME: workaround for Kivy a95d67f (and above?), Carousel.current_slide
-                #        binding now calls onSlideChange twice with the same widget (here
-                #        "new_slide"). To be checked with Kivy team.
-                return
-            G.host._removeVisibleWidget(self._former_slide)
+            G.host._removeVisibleWidget(self._former_slide, ignore_missing=True)
         self._former_slide = new_slide
-        if new_slide is not None:
-            G.host._addVisibleWidget(new_slide)
-            self.updateHiddenSlides()
+        if self.carousel_active:
+            G.host.selected_widget = new_slide
+            if new_slide is not None:
+                G.host._addVisibleWidget(new_slide)
+                self._updateHiddenSlides()
 
-    def hiddenList(self, visible_list):
-        """return widgets of same class as holded one which are hidden
+    def hiddenList(self, visible_list, ignore=None):
+        """return widgets of same class as carousel current one, if they are hidden
 
         @param visible_list(list[QuickWidget]): widgets visible
+        @param ignore(QuickWidget, None): do no return this widget
         @return (iter[QuickWidget]): widgets hidden
         """
-        added = [(w.targets, w.profiles) for w in visible_list]  # we want to avoid recreated widgets
-        for w in G.host.widgets.getWidgets(self.current_slide.__class__, profiles=self.current_slide.profiles):
-            if w in visible_list or (w.targets, w.profiles) in added:
+        # we want to avoid recreated widgets
+        added = [w.widget_hash for w in visible_list]
+        current_slide = self.carousel.current_slide
+        for w in G.host.widgets.getWidgets(current_slide.__class__,
+                                           profiles=current_slide.profiles):
+            wid_hash = w.widget_hash
+            if w in visible_list or wid_hash in added:
+                continue
+            if wid_hash == ignore.widget_hash:
                 continue
             yield w
 
@@ -361,24 +500,27 @@
         except AttributeError:
             return str(list(widget.targets)[0]).lower()
 
-    def updateHiddenSlides(self):
+    def _updateHiddenSlides(self):
         """adjust carousel slides according to visible widgets"""
-        if self._slides_update_lock:
+        if self._slides_update_lock or not self.carousel_active:
             return
-        if not isinstance(self.carousel.current_slide, quick_widgets.QuickWidget):
+        current_slide = self.carousel.current_slide
+        if not isinstance(current_slide, quick_widgets.QuickWidget):
             return
         # lock must be used here to avoid recursions
-        self._slides_update_lock = True
-        visible_list = G.host.getVisibleList(self.current_slide.__class__)
-        hidden = list(self.hiddenList(visible_list))
-        slides_sorted =  sorted(hidden + [self.carousel.current_slide], key=self.widgets_sort)
-        to_remove = set(self.carousel.slides).difference({self.carousel.current_slide})
+        self._slides_update_lock += 1
+        visible_list = G.host.getVisibleList(current_slide.__class__)
+        # we ignore current_slide as it may not be visible yet (e.g. if an other
+        # screen is shown
+        hidden = list(self.hiddenList(visible_list, ignore=current_slide))
+        slides_sorted =  sorted(set(hidden + [current_slide]), key=self.widgets_sort)
+        to_remove = set(self.carousel.slides).difference({current_slide})
         for w in to_remove:
             self.carousel.remove_widget(w)
         if hidden:
             # no need to add more than two widgets (next and previous),
             # as the list will be updated on each new visible widget
-            current_idx = slides_sorted.index(self.current_slide)
+            current_idx = slides_sorted.index(current_slide)
             try:
                 next_slide = slides_sorted[current_idx+1]
             except IndexError:
@@ -388,7 +530,7 @@
                 previous_slide = slides_sorted[current_idx-1]
                 self.carousel.add_widget(G.host.getOrClone(previous_slide))
 
-        self._slides_update_lock = False
+        self._slides_update_lock -= 1
 
 
 class WidgetsHandlerLayout(Layout):
@@ -476,9 +618,4 @@
 
     def __init__(self, **kw):
         super(WidgetsHandler, self).__init__(**kw)
-        self.wrapper = self.add_widget()
-
-    @property
-    def cagou_widget(self):
-        """get holded CagouWidget"""
-        return self.wrapper.current_slide
+        self.add_widget()
--- a/cagou/kv/widgets_handler.kv	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/kv/widgets_handler.kv	Fri Jan 17 18:44:35 2020 +0100
@@ -15,7 +15,6 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 <WHWrapper>:
-    carousel: carousel
     _sp_top_y: self.y + self.height - self.sp_size
     padding: self.split_size + self.split_margin, self.split_size + self.split_margin, 0, 0
 
@@ -42,9 +41,3 @@
             # top
             points: self.x + self.width / 2 - self.sp_size - self.sp_space, root._sp_top_y,  self.x + self.width / 2, root._sp_top_y, self.x + self.width / 2 + self.sp_size + self.sp_space, root._sp_top_y
             pointsize: self.sp_size
-
-    Carousel:
-        id: carousel
-        direction: 'right'
-        ignore_perpendicular_swipes: True
-        loop: True
--- a/cagou/plugins/plugin_wid_chat.py	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/plugins/plugin_wid_chat.py	Fri Jan 17 18:44:35 2020 +0100
@@ -380,9 +380,12 @@
     message_input = properties.ObjectProperty()
     messages_widget = properties.ObjectProperty()
     history_scroll = properties.ObjectProperty()
+    collection_carousel = True
 
     def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
                  subject=None, statuses=None, profiles=None):
+        if statuses is None:
+            statuses = {}
         quick_chat.QuickChat.__init__(
             self, host, target, type_, nick, occupants, subject, statuses,
             profiles=profiles)
@@ -404,9 +407,11 @@
         self.headerInputAddExtra(extra_btn)
         self.header_input.hint_text = target
         self._history_prepend_lock = False
-        Clock.schedule_once(lambda dt: self.postInit(), 0)
         self.history_count = 0
 
+    def on_kv_post(self, __):
+        self.postInit()
+
     def __str__(self):
         return "Chat({})".format(self.target)
 
--- a/cagou/plugins/plugin_wid_widget_selector.kv	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/plugins/plugin_wid_widget_selector.kv	Fri Jan 17 18:44:35 2020 +0100
@@ -39,3 +39,5 @@
 
 <WidgetSelector>:
     spacing: dp(10)
+    size_hint: 1, None
+    height: self.minimum_height
--- a/cagou/plugins/plugin_wid_widget_selector.py	Fri Jan 17 18:44:35 2020 +0100
+++ b/cagou/plugins/plugin_wid_widget_selector.py	Fri Jan 17 18:44:35 2020 +0100
@@ -1,5 +1,4 @@
-#!/usr/bin/python
-# -*- coding: utf-8 -*-
+#!/usr/bin/env python3
 
 # Cagou: desktop/mobile frontend for Salut à Toi XMPP client
 # Copyright (C) 2016-2019 Jérôme Poisson (goffi@goffi.org)