Daniel Brötzmann pushed to branch master at gajim / gajim

Commits:

5 changed files:

Changes:

  • gajim/gtk/chat_action_processor.py
    1
    +# This file is part of Gajim.
    
    2
    +#
    
    3
    +# Gajim is free software; you can redistribute it and/or modify
    
    4
    +# it under the terms of the GNU General Public License as published
    
    5
    +# by the Free Software Foundation; version 3 only.
    
    6
    +#
    
    7
    +# Gajim is distributed in the hope that it will be useful,
    
    8
    +# but WITHOUT ANY WARRANTY; without even the implied warranty of
    
    9
    +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    
    10
    +# GNU General Public License for more details.
    
    11
    +#
    
    12
    +# You should have received a copy of the GNU General Public License
    
    13
    +# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
    
    14
    +
    
    15
    +from __future__ import annotations
    
    16
    +
    
    17
    +from typing import cast
    
    18
    +from typing import Optional
    
    19
    +from typing import TYPE_CHECKING
    
    20
    +
    
    21
    +from gi.repository import Gdk
    
    22
    +from gi.repository import Gio
    
    23
    +from gi.repository import GLib
    
    24
    +from gi.repository import Gtk
    
    25
    +
    
    26
    +from gajim.common import app
    
    27
    +from gajim.common.const import Direction
    
    28
    +
    
    29
    +from .emoji_data_gtk import get_emoji_data
    
    30
    +if TYPE_CHECKING:
    
    31
    +    from .message_input import MessageInputTextView
    
    32
    +
    
    33
    +MAX_ENTRIES = 5
    
    34
    +
    
    35
    +
    
    36
    +class ChatActionProcessor(Gtk.Popover):
    
    37
    +    def __init__(self, message_input: MessageInputTextView) -> None:
    
    38
    +        Gtk.Popover.__init__(self)
    
    39
    +        self._menu = Gio.Menu()
    
    40
    +        self.bind_model(self._menu)
    
    41
    +        self.set_relative_to(message_input)
    
    42
    +        self.set_position(Gtk.PositionType.TOP)
    
    43
    +        self.set_modal(False)
    
    44
    +        self.set_size_request(250, -1)
    
    45
    +        self.connect('closed', self._on_popover_closed)
    
    46
    +        self.connect('destroy', self._on_destroy)
    
    47
    +
    
    48
    +        self._message_input = message_input
    
    49
    +        self._message_input.connect('key-press-event', self._on_key_press)
    
    50
    +
    
    51
    +        self._buf = message_input.get_buffer()
    
    52
    +        self._buf.connect('changed', self._on_changed)
    
    53
    +
    
    54
    +        self._start_mark: Optional[Gtk.TextMark] = None
    
    55
    +        self._current_iter: Optional[Gtk.TextIter] = None
    
    56
    +
    
    57
    +        self._active = False
    
    58
    +
    
    59
    +    def _on_destroy(self, _popover: Gtk.Popover) -> None:
    
    60
    +        app.check_finalize(self)
    
    61
    +
    
    62
    +    def _on_key_press(self,
    
    63
    +                      _textview: Gtk.TextView,
    
    64
    +                      event: Gdk.EventKey
    
    65
    +                      ) -> bool:
    
    66
    +        if not self._active:
    
    67
    +            return False
    
    68
    +
    
    69
    +        if event.keyval == Gdk.KEY_Up:
    
    70
    +            self._move_selection(Direction.PREV)
    
    71
    +            return True
    
    72
    +
    
    73
    +        if event.keyval == Gdk.KEY_Down:
    
    74
    +            self._move_selection(Direction.NEXT)
    
    75
    +            return True
    
    76
    +
    
    77
    +        if event.keyval == Gdk.KEY_Return:
    
    78
    +            selected_action = self._get_selected_action()
    
    79
    +            self._replace_text(selected_action)
    
    80
    +            self.popdown()
    
    81
    +            return True
    
    82
    +
    
    83
    +        if event.keyval == Gdk.KEY_Tab:
    
    84
    +            selected_action = self._get_selected_action()
    
    85
    +            self._replace_text(selected_action)
    
    86
    +            self.popdown()
    
    87
    +            return True
    
    88
    +
    
    89
    +        return False
    
    90
    +
    
    91
    +    def _on_popover_closed(self, _popover: Gtk.Popover) -> None:
    
    92
    +        self._active = False
    
    93
    +        self._message_input.grab_focus()
    
    94
    +
    
    95
    +    def _get_text(self) -> str:
    
    96
    +        start, end = self._buf.get_bounds()
    
    97
    +        return self._buf.get_text(start, end, True)
    
    98
    +
    
    99
    +    def _replace_text(self, selected_action: str) -> None:
    
    100
    +        assert self._start_mark is not None
    
    101
    +        start_iter = self._buf.get_iter_at_mark(self._start_mark)
    
    102
    +        assert self._current_iter is not None
    
    103
    +        self._buf.delete(start_iter, self._current_iter)
    
    104
    +        self._buf.insert(start_iter, selected_action)
    
    105
    +
    
    106
    +    def _get_commands(self) -> list[str]:
    
    107
    +        commands: list[str] = []
    
    108
    +        control = app.window.get_control(
    
    109
    +            self._message_input.account, self._message_input.contact.jid)
    
    110
    +        assert control is not None
    
    111
    +        for command in control.list_commands():
    
    112
    +            for name in command.names:
    
    113
    +                commands.append(name)
    
    114
    +        return commands
    
    115
    +
    
    116
    +    def _on_changed(self, _textview: MessageInputTextView) -> None:
    
    117
    +        insert = self._buf.get_insert()
    
    118
    +        self._current_iter = self._buf.get_iter_at_mark(insert)
    
    119
    +        current_offset = self._current_iter.get_offset()
    
    120
    +
    
    121
    +        if self._start_mark is None:
    
    122
    +            start_iter = self._buf.get_iter_at_offset(current_offset - 1)
    
    123
    +        else:
    
    124
    +            start_iter = self._buf.get_iter_at_mark(self._start_mark)
    
    125
    +
    
    126
    +        command_found = self._check_for_command(start_iter)
    
    127
    +        emoji_found = self._check_for_emoji(start_iter)
    
    128
    +
    
    129
    +        if not command_found and not emoji_found:
    
    130
    +            if self._start_mark is not None:
    
    131
    +                self._buf.delete_mark(self._start_mark)
    
    132
    +                self._start_mark = None
    
    133
    +            self.popdown()
    
    134
    +
    
    135
    +    def _check_for_command(self, start_iter: Gtk.TextIter) -> bool:
    
    136
    +        assert self._current_iter is not None
    
    137
    +        search = self._current_iter.backward_search(
    
    138
    +            '/',
    
    139
    +            Gtk.TextSearchFlags.VISIBLE_ONLY,
    
    140
    +            start_iter)
    
    141
    +
    
    142
    +        if search is not None:
    
    143
    +            start, _end = search
    
    144
    +            if start.get_offset() > 0:
    
    145
    +                # '/' not at the beginning
    
    146
    +                return False
    
    147
    +
    
    148
    +            action_text = self._buf.get_text(start, self._current_iter, False)
    
    149
    +            if self._start_mark is None:
    
    150
    +                self._start_mark = Gtk.TextMark.new('chat-action-start', True)
    
    151
    +                self._buf.add_mark(self._start_mark, start)
    
    152
    +            self._update_commands_menu(action_text, start)
    
    153
    +            return True
    
    154
    +
    
    155
    +        return False
    
    156
    +
    
    157
    +    def _update_commands_menu(self,
    
    158
    +                              action_text: str,
    
    159
    +                              start: Gtk.TextIter
    
    160
    +                              ) -> None:
    
    161
    +        self._menu.remove_all()
    
    162
    +        command_list = self._get_commands()
    
    163
    +        for command in command_list:
    
    164
    +            if not command.startswith(action_text[1:]):
    
    165
    +                continue
    
    166
    +            if command_list.index(command) >= MAX_ENTRIES:
    
    167
    +                continue
    
    168
    +            action_data = GLib.Variant('s', f'/{command}')
    
    169
    +            menu_item = Gio.MenuItem()
    
    170
    +            menu_item.set_label(f'/{command}')
    
    171
    +            menu_item.set_attribute_value('action-data', action_data)
    
    172
    +            self._menu.append_item(menu_item)
    
    173
    +
    
    174
    +        if self._menu.get_n_items() > 0:
    
    175
    +            self._show_menu(start)
    
    176
    +        else:
    
    177
    +            self.popdown()
    
    178
    +
    
    179
    +    def _check_for_emoji(self, start_iter: Gtk.TextIter) -> bool:
    
    180
    +        assert self._current_iter is not None
    
    181
    +        search = self._current_iter.backward_search(
    
    182
    +            ':',
    
    183
    +            Gtk.TextSearchFlags.CASE_INSENSITIVE,
    
    184
    +            start_iter)
    
    185
    +
    
    186
    +        if search is not None:
    
    187
    +            start, _end = search
    
    188
    +            colon_offset = start.get_offset()
    
    189
    +            before_colon = self._buf.get_iter_at_offset(colon_offset - 1)
    
    190
    +            if before_colon.get_char() != ' ':
    
    191
    +                # We want to show the menu only if text begins with a colon,
    
    192
    +                # or if a colon follows on a space. This avoids showing the
    
    193
    +                # menu within normal sentences containing colons.
    
    194
    +                text = self._get_text()
    
    195
    +                if not text.startswith(':'):
    
    196
    +                    return False
    
    197
    +
    
    198
    +            action_text = self._buf.get_text(start, self._current_iter, False)
    
    199
    +            if self._start_mark is None:
    
    200
    +                self._start_mark = Gtk.TextMark.new('chat-action-start', True)
    
    201
    +                self._buf.add_mark(self._start_mark, start)
    
    202
    +            self._update_emoji_menu(action_text, start)
    
    203
    +            return True
    
    204
    +
    
    205
    +        return False
    
    206
    +
    
    207
    +    def _update_emoji_menu(self,
    
    208
    +                           action_text: str,
    
    209
    +                           start: Gtk.TextIter
    
    210
    +                           ) -> None:
    
    211
    +        self._menu.remove_all()
    
    212
    +        emoji_data = get_emoji_data()
    
    213
    +        menu_entry_count = 0
    
    214
    +        for shortcode, codepoint in emoji_data.items():
    
    215
    +            if not shortcode.startswith(action_text[1:]):
    
    216
    +                continue
    
    217
    +            if menu_entry_count >= MAX_ENTRIES:
    
    218
    +                continue
    
    219
    +            action_data = GLib.Variant('s', codepoint)
    
    220
    +            menu_item = Gio.MenuItem()
    
    221
    +            menu_item.set_label(f'{codepoint} {shortcode}')
    
    222
    +            menu_item.set_attribute_value('action-data', action_data)
    
    223
    +            self._menu.append_item(menu_item)
    
    224
    +            menu_entry_count += 1
    
    225
    +
    
    226
    +        if self._menu.get_n_items() > 0:
    
    227
    +            self._show_menu(start)
    
    228
    +        else:
    
    229
    +            self.popdown()
    
    230
    +
    
    231
    +    def _show_menu(self, start: Gtk.TextIter) -> None:
    
    232
    +        self._active = True
    
    233
    +        rectangle = self._message_input.get_iter_location(start)
    
    234
    +        self.set_pointing_to(rectangle)
    
    235
    +        self.popup()
    
    236
    +        menu_items = self._get_menu_items()
    
    237
    +        menu_items[0].set_state_flags(
    
    238
    +            Gtk.StateFlags.FOCUSED | Gtk.StateFlags.PRELIGHT, False)
    
    239
    +        for item in menu_items:
    
    240
    +            item.connect(
    
    241
    +                'clicked', self._on_item_clicked, menu_items.index(item))
    
    242
    +
    
    243
    +    def _on_item_clicked(self, _button: Gtk.MenuButton, index: int) -> None:
    
    244
    +        variant = self._menu.get_item_attribute_value(
    
    245
    +            index, 'action-data')
    
    246
    +        self._replace_text(variant.get_string())
    
    247
    +        self.popdown()
    
    248
    +
    
    249
    +    def _get_menu_items(self) -> list[Gtk.ModelButton]:
    
    250
    +        stack = cast(Gtk.Stack, self.get_children()[0])
    
    251
    +        menu_section_box = cast(Gtk.Box, stack.get_children()[0])
    
    252
    +        box = cast(Gtk.Box, menu_section_box.get_children()[0])
    
    253
    +        items = cast(list[Gtk.ModelButton], box.get_children())
    
    254
    +        return items
    
    255
    +
    
    256
    +    def _get_selected_action(self) -> str:
    
    257
    +        items = self._get_menu_items()
    
    258
    +        for item in items:
    
    259
    +            if self._item_has_focus(item):
    
    260
    +                variant = self._menu.get_item_attribute_value(
    
    261
    +                    items.index(item), 'action-data')
    
    262
    +                return variant.get_string()
    
    263
    +        return ''
    
    264
    +
    
    265
    +    def _move_selection(self, direction: Direction) -> None:
    
    266
    +        # The popover cannot have real focus, since we want to continue
    
    267
    +        # writing in the message input. Instead, we emulate focus/prelight
    
    268
    +        # state for menu items when pressing Up/Down arrows. Reference:
    
    269
    +        # https://gitlab.gnome.org/GNOME/gtk/-/blob/main/gtk/
    
    270
    +        # gtkemojicompletion.c
    
    271
    +        items = self._get_menu_items()
    
    272
    +        num_items = len(items)
    
    273
    +        if num_items == 1:
    
    274
    +            return
    
    275
    +
    
    276
    +        new_item = items[0]  # default item
    
    277
    +        for item in items:
    
    278
    +            if self._item_has_focus(item):
    
    279
    +                item.unset_state_flags(
    
    280
    +                    Gtk.StateFlags.FOCUSED | Gtk.StateFlags.PRELIGHT)
    
    281
    +            else:
    
    282
    +                continue
    
    283
    +
    
    284
    +            if direction is Direction.NEXT:
    
    285
    +                if items.index(item) == num_items - 1:
    
    286
    +                    # Select first (default)
    
    287
    +                    break
    
    288
    +                new_item = items[items.index(item) + 1]
    
    289
    +                break
    
    290
    +
    
    291
    +            if items.index(item) == 0:
    
    292
    +                new_item = items[num_items -1]
    
    293
    +                break
    
    294
    +            new_item = items[items.index(item) - 1]
    
    295
    +            break
    
    296
    +
    
    297
    +        new_item.set_state_flags(
    
    298
    +            Gtk.StateFlags.FOCUSED | Gtk.StateFlags.PRELIGHT, False)
    
    299
    +
    
    300
    +    @staticmethod
    
    301
    +    def _item_has_focus(item: Gtk.ModelButton) -> bool:
    
    302
    +        flags = item.get_state_flags()
    
    303
    +        return 'GTK_STATE_FLAG_FOCUSED' in str(flags)

  • gajim/gtk/controls/base.py
    ... ... @@ -213,7 +213,7 @@ def __init__(self, widget_name: str, account: str, jid: JID) -> None:
    213 213
                 account, self.contact)
    
    214 214
             self.xml.hbox.pack_start(self._security_label_selector, False, True, 0)
    
    215 215
     
    
    216
    -        self.msg_textview = MessageInputTextView()
    
    216
    +        self.msg_textview = MessageInputTextView(self.account, self.contact)
    
    217 217
             self.msg_textview.connect('paste-clipboard',
    
    218 218
                                       self._on_message_textview_paste_event)
    
    219 219
             self.msg_textview.connect('key-press-event',
    

  • gajim/gtk/emoji_data_gtk.py
    1
    +# This file is part of Gajim.
    
    2
    +#
    
    3
    +# Gajim is free software; you can redistribute it and/or modify
    
    4
    +# it under the terms of the GNU General Public License as published
    
    5
    +# by the Free Software Foundation; version 3 only.
    
    6
    +#
    
    7
    +# Gajim is distributed in the hope that it will be useful,
    
    8
    +# but WITHOUT ANY WARRANTY; without even the implied warranty of
    
    9
    +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    
    10
    +# GNU General Public License for more details.
    
    11
    +#
    
    12
    +# You should have received a copy of the GNU General Public License
    
    13
    +# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
    
    14
    +
    
    15
    +from __future__ import annotations
    
    16
    +
    
    17
    +from typing import Any
    
    18
    +
    
    19
    +import logging
    
    20
    +from packaging.version import Version as V
    
    21
    +
    
    22
    +from gi.repository import Gio
    
    23
    +from gi.repository import GLib
    
    24
    +
    
    25
    +from .util import get_gtk_version
    
    26
    +
    
    27
    +log = logging.getLogger('gajim.gui.emoji_data_gtk')
    
    28
    +
    
    29
    +
    
    30
    +def get_emoji_data() -> dict[str, str]:
    
    31
    +    return emoji_data
    
    32
    +
    
    33
    +
    
    34
    +def load_emoji_data() -> dict[str, str]:
    
    35
    +    # Location of emoji data resource changed in GTK >= 3.24.30
    
    36
    +    if V(get_gtk_version()) >= V('3.24.30'):
    
    37
    +        emoji_data_resource = '/org/gtk/libgtk/emoji/en.data'
    
    38
    +    else:
    
    39
    +        emoji_data_resource = '/org/gtk/libgtk/emoji/emoji.data'
    
    40
    +
    
    41
    +    try:
    
    42
    +        bytes_ = Gio.resources_lookup_data(
    
    43
    +            emoji_data_resource,
    
    44
    +            Gio.ResourceLookupFlags.NONE)
    
    45
    +        assert bytes_ is not None
    
    46
    +        # We have to store bytes_data in a variable to make sure Python
    
    47
    +        # keeps it until it is processed.
    
    48
    +        bytes_data = bytes_.get_data()
    
    49
    +
    
    50
    +        def _pass(_user_data: Any) -> None:
    
    51
    +            pass
    
    52
    +
    
    53
    +        variant = GLib.Variant.new_from_data(
    
    54
    +            GLib.VariantType('a(auss)'),
    
    55
    +            bytes_data,
    
    56
    +            True,
    
    57
    +            _pass)
    
    58
    +
    
    59
    +        emoji_data_dict: dict[str, str] = {}
    
    60
    +        index = 0
    
    61
    +        for _item in variant:
    
    62
    +            emoji = variant.get_child_value(index)
    
    63
    +            points = emoji.get_child_value(0)
    
    64
    +            codepoint = points.get_child_value(0).get_uint32()
    
    65
    +            shortcode = emoji.get_child_value(1).get_string()
    
    66
    +            emoji_data_dict[shortcode] = chr(codepoint)
    
    67
    +            index += 1
    
    68
    +
    
    69
    +        # Add commonly used shortcodes
    
    70
    +        emoji_data_dict['+1'] = '\U0001F44D'
    
    71
    +        emoji_data_dict['-1'] = '\U0001F44E'
    
    72
    +        return emoji_data_dict
    
    73
    +
    
    74
    +    except GLib.Error:
    
    75
    +        return {}
    
    76
    +
    
    77
    +
    
    78
    +emoji_data = load_emoji_data()

  • gajim/gtk/message_input.py
    ... ... @@ -30,7 +30,9 @@
    30 30
     from gajim.common import app
    
    31 31
     from gajim.common.styling import process
    
    32 32
     from gajim.common.styling import PlainBlock
    
    33
    +from gajim.common.types import ChatContactT
    
    33 34
     
    
    35
    +from .chat_action_processor import ChatActionProcessor
    
    34 36
     from .util import scroll_to_end
    
    35 37
     
    
    36 38
     if app.is_installed('GSPELL'):
    
    ... ... @@ -50,8 +52,11 @@ class MessageInputTextView(Gtk.TextView):
    50 52
         """
    
    51 53
         A Gtk.Textview for chat message input
    
    52 54
         """
    
    53
    -    def __init__(self) -> None:
    
    55
    +    def __init__(self, account: str, contact: ChatContactT) -> None:
    
    54 56
             Gtk.TextView.__init__(self)
    
    57
    +        self.account = account
    
    58
    +        self.contact = contact
    
    59
    +
    
    55 60
             self.set_border_width(3)
    
    56 61
             self.set_accepts_tab(True)
    
    57 62
             self.set_editable(True)
    
    ... ... @@ -68,6 +73,8 @@ def __init__(self) -> None:
    68 73
             self._undo_list: list[str] = []
    
    69 74
             self.undo_pressed: bool = False
    
    70 75
     
    
    76
    +        self._chat_action_processor = ChatActionProcessor(self)
    
    77
    +
    
    71 78
             self.get_buffer().create_tag('strong', weight=Pango.Weight.BOLD)
    
    72 79
             self.get_buffer().create_tag('emphasis', style=Pango.Style.ITALIC)
    
    73 80
             self.get_buffer().create_tag('strike', strikethrough=True)
    
    ... ... @@ -89,6 +96,8 @@ def _on_destroy(self, _widget: Gtk.Widget) -> None:
    89 96
                 Gtk.DestDefaults.ALL,
    
    90 97
                 None,
    
    91 98
                 Gdk.DragAction.DEFAULT)
    
    99
    +        self._chat_action_processor.destroy()
    
    100
    +        app.check_finalize(self)
    
    92 101
     
    
    93 102
         def _on_focus_in(self,
    
    94 103
                          _widget: Gtk.Widget,
    

  • pyrightconfig.json
    ... ... @@ -57,6 +57,7 @@
    57 57
             "gajim/gtk/call_window.py",
    
    58 58
             "gajim/gtk/certificate_dialog.py",
    
    59 59
             "gajim/gtk/change_password.py",
    
    60
    +        "gajim/gtk/chat_action_processor.py",
    
    60 61
             "gajim/gtk/chat_filter.py",
    
    61 62
             "gajim/gtk/chat_list_stack.py",
    
    62 63
             "gajim/gtk/chat_list.py",
    
    ... ... @@ -68,6 +69,7 @@
    68 69
             "gajim/gtk/contact_settings.py",
    
    69 70
             "gajim/gtk/conversation",
    
    70 71
             "gajim/gtk/dialogs.py",
    
    72
    +        "gajim/gtk/emoji_data_gtk.py",
    
    71 73
             "gajim/gtk/exception.py",
    
    72 74
             "gajim/gtk/features.py",
    
    73 75
             "gajim/gtk/file_transfer_send.py",