Daniel Brötzmann pushed to branch master at gajim / gajim
Commits: 86c52d6b by wurstsalat at 2022-06-06T10:37:05+02:00 feat: Add ChatActionProcessor
- - - - -
5 changed files:
- + gajim/gtk/chat_action_processor.py - gajim/gtk/controls/base.py - + gajim/gtk/emoji_data_gtk.py - gajim/gtk/message_input.py - pyrightconfig.json
Changes:
===================================== gajim/gtk/chat_action_processor.py ===================================== @@ -0,0 +1,303 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see http://www.gnu.org/licenses/. + +from __future__ import annotations + +from typing import cast +from typing import Optional +from typing import TYPE_CHECKING + +from gi.repository import Gdk +from gi.repository import Gio +from gi.repository import GLib +from gi.repository import Gtk + +from gajim.common import app +from gajim.common.const import Direction + +from .emoji_data_gtk import get_emoji_data +if TYPE_CHECKING: + from .message_input import MessageInputTextView + +MAX_ENTRIES = 5 + + +class ChatActionProcessor(Gtk.Popover): + def __init__(self, message_input: MessageInputTextView) -> None: + Gtk.Popover.__init__(self) + self._menu = Gio.Menu() + self.bind_model(self._menu) + self.set_relative_to(message_input) + self.set_position(Gtk.PositionType.TOP) + self.set_modal(False) + self.set_size_request(250, -1) + self.connect('closed', self._on_popover_closed) + self.connect('destroy', self._on_destroy) + + self._message_input = message_input + self._message_input.connect('key-press-event', self._on_key_press) + + self._buf = message_input.get_buffer() + self._buf.connect('changed', self._on_changed) + + self._start_mark: Optional[Gtk.TextMark] = None + self._current_iter: Optional[Gtk.TextIter] = None + + self._active = False + + def _on_destroy(self, _popover: Gtk.Popover) -> None: + app.check_finalize(self) + + def _on_key_press(self, + _textview: Gtk.TextView, + event: Gdk.EventKey + ) -> bool: + if not self._active: + return False + + if event.keyval == Gdk.KEY_Up: + self._move_selection(Direction.PREV) + return True + + if event.keyval == Gdk.KEY_Down: + self._move_selection(Direction.NEXT) + return True + + if event.keyval == Gdk.KEY_Return: + selected_action = self._get_selected_action() + self._replace_text(selected_action) + self.popdown() + return True + + if event.keyval == Gdk.KEY_Tab: + selected_action = self._get_selected_action() + self._replace_text(selected_action) + self.popdown() + return True + + return False + + def _on_popover_closed(self, _popover: Gtk.Popover) -> None: + self._active = False + self._message_input.grab_focus() + + def _get_text(self) -> str: + start, end = self._buf.get_bounds() + return self._buf.get_text(start, end, True) + + def _replace_text(self, selected_action: str) -> None: + assert self._start_mark is not None + start_iter = self._buf.get_iter_at_mark(self._start_mark) + assert self._current_iter is not None + self._buf.delete(start_iter, self._current_iter) + self._buf.insert(start_iter, selected_action) + + def _get_commands(self) -> list[str]: + commands: list[str] = [] + control = app.window.get_control( + self._message_input.account, self._message_input.contact.jid) + assert control is not None + for command in control.list_commands(): + for name in command.names: + commands.append(name) + return commands + + def _on_changed(self, _textview: MessageInputTextView) -> None: + insert = self._buf.get_insert() + self._current_iter = self._buf.get_iter_at_mark(insert) + current_offset = self._current_iter.get_offset() + + if self._start_mark is None: + start_iter = self._buf.get_iter_at_offset(current_offset - 1) + else: + start_iter = self._buf.get_iter_at_mark(self._start_mark) + + command_found = self._check_for_command(start_iter) + emoji_found = self._check_for_emoji(start_iter) + + if not command_found and not emoji_found: + if self._start_mark is not None: + self._buf.delete_mark(self._start_mark) + self._start_mark = None + self.popdown() + + def _check_for_command(self, start_iter: Gtk.TextIter) -> bool: + assert self._current_iter is not None + search = self._current_iter.backward_search( + '/', + Gtk.TextSearchFlags.VISIBLE_ONLY, + start_iter) + + if search is not None: + start, _end = search + if start.get_offset() > 0: + # '/' not at the beginning + return False + + action_text = self._buf.get_text(start, self._current_iter, False) + if self._start_mark is None: + self._start_mark = Gtk.TextMark.new('chat-action-start', True) + self._buf.add_mark(self._start_mark, start) + self._update_commands_menu(action_text, start) + return True + + return False + + def _update_commands_menu(self, + action_text: str, + start: Gtk.TextIter + ) -> None: + self._menu.remove_all() + command_list = self._get_commands() + for command in command_list: + if not command.startswith(action_text[1:]): + continue + if command_list.index(command) >= MAX_ENTRIES: + continue + action_data = GLib.Variant('s', f'/{command}') + menu_item = Gio.MenuItem() + menu_item.set_label(f'/{command}') + menu_item.set_attribute_value('action-data', action_data) + self._menu.append_item(menu_item) + + if self._menu.get_n_items() > 0: + self._show_menu(start) + else: + self.popdown() + + def _check_for_emoji(self, start_iter: Gtk.TextIter) -> bool: + assert self._current_iter is not None + search = self._current_iter.backward_search( + ':', + Gtk.TextSearchFlags.CASE_INSENSITIVE, + start_iter) + + if search is not None: + start, _end = search + colon_offset = start.get_offset() + before_colon = self._buf.get_iter_at_offset(colon_offset - 1) + if before_colon.get_char() != ' ': + # We want to show the menu only if text begins with a colon, + # or if a colon follows on a space. This avoids showing the + # menu within normal sentences containing colons. + text = self._get_text() + if not text.startswith(':'): + return False + + action_text = self._buf.get_text(start, self._current_iter, False) + if self._start_mark is None: + self._start_mark = Gtk.TextMark.new('chat-action-start', True) + self._buf.add_mark(self._start_mark, start) + self._update_emoji_menu(action_text, start) + return True + + return False + + def _update_emoji_menu(self, + action_text: str, + start: Gtk.TextIter + ) -> None: + self._menu.remove_all() + emoji_data = get_emoji_data() + menu_entry_count = 0 + for shortcode, codepoint in emoji_data.items(): + if not shortcode.startswith(action_text[1:]): + continue + if menu_entry_count >= MAX_ENTRIES: + continue + action_data = GLib.Variant('s', codepoint) + menu_item = Gio.MenuItem() + menu_item.set_label(f'{codepoint} {shortcode}') + menu_item.set_attribute_value('action-data', action_data) + self._menu.append_item(menu_item) + menu_entry_count += 1 + + if self._menu.get_n_items() > 0: + self._show_menu(start) + else: + self.popdown() + + def _show_menu(self, start: Gtk.TextIter) -> None: + self._active = True + rectangle = self._message_input.get_iter_location(start) + self.set_pointing_to(rectangle) + self.popup() + menu_items = self._get_menu_items() + menu_items[0].set_state_flags( + Gtk.StateFlags.FOCUSED | Gtk.StateFlags.PRELIGHT, False) + for item in menu_items: + item.connect( + 'clicked', self._on_item_clicked, menu_items.index(item)) + + def _on_item_clicked(self, _button: Gtk.MenuButton, index: int) -> None: + variant = self._menu.get_item_attribute_value( + index, 'action-data') + self._replace_text(variant.get_string()) + self.popdown() + + def _get_menu_items(self) -> list[Gtk.ModelButton]: + stack = cast(Gtk.Stack, self.get_children()[0]) + menu_section_box = cast(Gtk.Box, stack.get_children()[0]) + box = cast(Gtk.Box, menu_section_box.get_children()[0]) + items = cast(list[Gtk.ModelButton], box.get_children()) + return items + + def _get_selected_action(self) -> str: + items = self._get_menu_items() + for item in items: + if self._item_has_focus(item): + variant = self._menu.get_item_attribute_value( + items.index(item), 'action-data') + return variant.get_string() + return '' + + def _move_selection(self, direction: Direction) -> None: + # The popover cannot have real focus, since we want to continue + # writing in the message input. Instead, we emulate focus/prelight + # state for menu items when pressing Up/Down arrows. Reference: + # https://gitlab.gnome.org/GNOME/gtk/-/blob/main/gtk/ + # gtkemojicompletion.c + items = self._get_menu_items() + num_items = len(items) + if num_items == 1: + return + + new_item = items[0] # default item + for item in items: + if self._item_has_focus(item): + item.unset_state_flags( + Gtk.StateFlags.FOCUSED | Gtk.StateFlags.PRELIGHT) + else: + continue + + if direction is Direction.NEXT: + if items.index(item) == num_items - 1: + # Select first (default) + break + new_item = items[items.index(item) + 1] + break + + if items.index(item) == 0: + new_item = items[num_items -1] + break + new_item = items[items.index(item) - 1] + break + + new_item.set_state_flags( + Gtk.StateFlags.FOCUSED | Gtk.StateFlags.PRELIGHT, False) + + @staticmethod + def _item_has_focus(item: Gtk.ModelButton) -> bool: + flags = item.get_state_flags() + 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: account, self.contact) self.xml.hbox.pack_start(self._security_label_selector, False, True, 0)
- self.msg_textview = MessageInputTextView() + self.msg_textview = MessageInputTextView(self.account, self.contact) self.msg_textview.connect('paste-clipboard', self._on_message_textview_paste_event) self.msg_textview.connect('key-press-event',
===================================== gajim/gtk/emoji_data_gtk.py ===================================== @@ -0,0 +1,78 @@ +# This file is part of Gajim. +# +# Gajim is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# Gajim 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Gajim. If not, see http://www.gnu.org/licenses/. + +from __future__ import annotations + +from typing import Any + +import logging +from packaging.version import Version as V + +from gi.repository import Gio +from gi.repository import GLib + +from .util import get_gtk_version + +log = logging.getLogger('gajim.gui.emoji_data_gtk') + + +def get_emoji_data() -> dict[str, str]: + return emoji_data + + +def load_emoji_data() -> dict[str, str]: + # Location of emoji data resource changed in GTK >= 3.24.30 + if V(get_gtk_version()) >= V('3.24.30'): + emoji_data_resource = '/org/gtk/libgtk/emoji/en.data' + else: + emoji_data_resource = '/org/gtk/libgtk/emoji/emoji.data' + + try: + bytes_ = Gio.resources_lookup_data( + emoji_data_resource, + Gio.ResourceLookupFlags.NONE) + assert bytes_ is not None + # We have to store bytes_data in a variable to make sure Python + # keeps it until it is processed. + bytes_data = bytes_.get_data() + + def _pass(_user_data: Any) -> None: + pass + + variant = GLib.Variant.new_from_data( + GLib.VariantType('a(auss)'), + bytes_data, + True, + _pass) + + emoji_data_dict: dict[str, str] = {} + index = 0 + for _item in variant: + emoji = variant.get_child_value(index) + points = emoji.get_child_value(0) + codepoint = points.get_child_value(0).get_uint32() + shortcode = emoji.get_child_value(1).get_string() + emoji_data_dict[shortcode] = chr(codepoint) + index += 1 + + # Add commonly used shortcodes + emoji_data_dict['+1'] = '\U0001F44D' + emoji_data_dict['-1'] = '\U0001F44E' + return emoji_data_dict + + except GLib.Error: + return {} + + +emoji_data = load_emoji_data()
===================================== gajim/gtk/message_input.py ===================================== @@ -30,7 +30,9 @@ from gajim.common import app from gajim.common.styling import process from gajim.common.styling import PlainBlock +from gajim.common.types import ChatContactT
+from .chat_action_processor import ChatActionProcessor from .util import scroll_to_end
if app.is_installed('GSPELL'): @@ -50,8 +52,11 @@ class MessageInputTextView(Gtk.TextView): """ A Gtk.Textview for chat message input """ - def __init__(self) -> None: + def __init__(self, account: str, contact: ChatContactT) -> None: Gtk.TextView.__init__(self) + self.account = account + self.contact = contact + self.set_border_width(3) self.set_accepts_tab(True) self.set_editable(True) @@ -68,6 +73,8 @@ def __init__(self) -> None: self._undo_list: list[str] = [] self.undo_pressed: bool = False
+ self._chat_action_processor = ChatActionProcessor(self) + self.get_buffer().create_tag('strong', weight=Pango.Weight.BOLD) self.get_buffer().create_tag('emphasis', style=Pango.Style.ITALIC) self.get_buffer().create_tag('strike', strikethrough=True) @@ -89,6 +96,8 @@ def _on_destroy(self, _widget: Gtk.Widget) -> None: Gtk.DestDefaults.ALL, None, Gdk.DragAction.DEFAULT) + self._chat_action_processor.destroy() + app.check_finalize(self)
def _on_focus_in(self, _widget: Gtk.Widget,
===================================== pyrightconfig.json ===================================== @@ -57,6 +57,7 @@ "gajim/gtk/call_window.py", "gajim/gtk/certificate_dialog.py", "gajim/gtk/change_password.py", + "gajim/gtk/chat_action_processor.py", "gajim/gtk/chat_filter.py", "gajim/gtk/chat_list_stack.py", "gajim/gtk/chat_list.py", @@ -68,6 +69,7 @@ "gajim/gtk/contact_settings.py", "gajim/gtk/conversation", "gajim/gtk/dialogs.py", + "gajim/gtk/emoji_data_gtk.py", "gajim/gtk/exception.py", "gajim/gtk/features.py", "gajim/gtk/file_transfer_send.py",
View it on GitLab: https://dev.gajim.org/gajim/gajim/-/commit/86c52d6bd49c5dde584ccc9e34eaa400a...