# 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 . import os import sys import logging import weakref from collections import OrderedDict from gi.repository import Gtk from gi.repository import GLib from gi.repository import GdkPixbuf from gi.repository import Pango from gajim.common import app from gajim.common import helpers from gajim.common import configpaths from gajim.gtk.util import get_builder from gajim.gtk.emoji_data import emoji_data from gajim.gtk.emoji_data import emoji_pixbufs from gajim.gtk.emoji_data import Emoji MODIFIER_MAX_CHILDREN_PER_LINE = 6 MAX_CHILDREN_PER_LINE = 10 log = logging.getLogger('gajim.emoji') class Section(Gtk.Box): def __init__(self, name, search_entry, press_cb, chooser): Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) self._chooser = chooser self._press_cb = press_cb self.pixbuf_generator = None self.heading = Gtk.Label(label=name) self.heading.set_halign(Gtk.Align.START) self.heading.get_style_context().add_class('emoji-chooser-heading') self.add(self.heading) self.flowbox = Gtk.FlowBox() self.flowbox.get_style_context().add_class('emoji-chooser-flowbox') self.flowbox.set_max_children_per_line(MAX_CHILDREN_PER_LINE) self.flowbox.set_filter_func(self._filter_func, search_entry) self.flowbox.connect('child-activated', press_cb) self.add(self.flowbox) self.show_all() def _filter_func(self, child, search_entry): name = search_entry.get_text() if not name: self.show() return True if name in child.desc: self.show() return True return False def add_emoji(self, codepoint, attrs): # Return always True, this method is used for the emoji factory # called by GLib.idle_add() pixbuf = self._get_next_pixbuf() variations = attrs.get('variations', None) if variations is None: if pixbuf is None: return True self.flowbox.add(EmojiChild(codepoint, pixbuf, attrs['desc'])) if pixbuf != 'font': # We save the pixbuf for fast access if we need to # replace a codepoint in the textview emoji_pixbufs[codepoint] = pixbuf else: if pixbuf is not None: chooser = ModifierChooser() # Iterate over the variations and add the codepoints for codepoint_ in variations.keys(): pixbuf_ = self._get_next_pixbuf() if pixbuf_ is None: continue if pixbuf_ == 'font': if not self._chooser._font_supports_codepoint( codepoint_): continue else: emoji_pixbufs[codepoint_] = pixbuf_ # Only codepoints are added which the # font or theme supports chooser.add_emoji(codepoint_, pixbuf_) # Check if we successfully added codepoints with modifiers if chooser.has_child: # If we have children then add a button # and set the popover child = EmojiModifierChild( codepoint, pixbuf, attrs['desc']) child.button.set_popover(chooser) chooser.flowbox.connect( 'child-activated', self._press_cb) else: # If no children were added, destroy the chooser # and add a EmojiChild instead of a EmojiModifierChild chooser.destroy() child = EmojiChild(codepoint, pixbuf, attrs['desc']) if pixbuf != 'font': emoji_pixbufs[codepoint] = pixbuf self.flowbox.add(child) else: # We dont have a image for the base codepoint # so skip all modifiers of it for _entry in variations: pixbuf = self._get_next_pixbuf() return True def clear_emojis(self): def _remove_emoji(emoji): self.flowbox.remove(emoji) emoji.destroy() self.flowbox.foreach(_remove_emoji) def _get_next_pixbuf(self): if self.pixbuf_generator is None: return 'font' return next(self.pixbuf_generator, False) class EmojiChild(Gtk.FlowBoxChild): def __init__(self, codepoint, pixbuf, desc): Gtk.FlowBoxChild.__init__(self) self.desc = desc self.codepoint = codepoint self.pixbuf = pixbuf if pixbuf == 'font': self.add(Gtk.Label(label=codepoint)) else: self.add(Gtk.Image.new_from_pixbuf(pixbuf)) self.set_tooltip_text(desc) self.show_all() def get_emoji(self): if self.pixbuf != 'font': pixbuf = self.get_child().get_pixbuf() pixbuf = pixbuf.scale_simple(Emoji.INPUT_SIZE, Emoji.INPUT_SIZE, GdkPixbuf.InterpType.HYPER) return self.codepoint, pixbuf return self.codepoint, None class EmojiModifierChild(Gtk.FlowBoxChild): def __init__(self, codepoint, pixbuf, desc): Gtk.FlowBoxChild.__init__(self) self.desc = desc self.codepoint = codepoint self.pixbuf = pixbuf self.button = Gtk.MenuButton() self.button.set_relief(Gtk.ReliefStyle.NONE) self.button.connect('button-press-event', self._button_press) if pixbuf == 'font': self.button.remove(self.button.get_child()) label = Gtk.Label(label=codepoint) self.button.add(label) else: self.button.get_child().set_from_pixbuf(pixbuf) self.set_tooltip_text(desc) self.add(self.button) self.show_all() def _button_press(self, button, event): if event.button == 3: button.get_popover().show() button.get_popover().get_child().unselect_all() return True if event.button == 1: self.get_parent().emit('child-activated', self) return True def get_emoji(self): if self.pixbuf != 'font': pixbuf = self.button.get_child().get_pixbuf() pixbuf = pixbuf.scale_simple(Emoji.INPUT_SIZE, Emoji.INPUT_SIZE, GdkPixbuf.InterpType.HYPER) return self.codepoint, pixbuf return self.codepoint, None class ModifierChooser(Gtk.Popover): def __init__(self): Gtk.Popover.__init__(self) self.set_name('EmoticonPopover') self._has_child = False self.flowbox = Gtk.FlowBox() self.flowbox.get_style_context().add_class( 'emoji-modifier-chooser-flowbox') self.flowbox.set_size_request(200, -1) self.flowbox.set_max_children_per_line(MODIFIER_MAX_CHILDREN_PER_LINE) self.flowbox.show() self.add(self.flowbox) @property def has_child(self): return self._has_child def add_emoji(self, codepoint, pixbuf): self.flowbox.add(EmojiChild(codepoint, pixbuf, None)) self._has_child = True class EmojiChooser(Gtk.Popover): _section_names = [ 'Smileys & People', 'Animals & Nature', 'Food & Drink', 'Travel & Places', 'Activities', 'Objects', 'Symbols', 'Flags' ] def __init__(self): super().__init__() self.set_name('EmoticonPopover') self._text_widget = None self._load_source_id = None self._pango_layout = Pango.Layout(self.get_pango_context()) self._builder = get_builder('emoji_chooser.ui') self._search = self._builder.get_object('search') self._stack = self._builder.get_object('stack') self._sections = OrderedDict() for name in self._section_names: self._sections[name] = Section( name, self._search, self._on_emoticon_press, self) section_box = self._builder.get_object('section_box') for section in self._sections.values(): section_box.add(section) self.add(self._builder.get_object('box')) self.connect('key-press-event', self._key_press) self._builder.connect_signals(self) self.show_all() @property def text_widget(self): return self._text_widget @text_widget.setter def text_widget(self, value): # Hold only a weak reference so if we can destroy # the ChatControl self._text_widget = weakref.ref(value) def _key_press(self, widget, event): return self._search.handle_event(event) def _search_changed(self, entry): for section in self._sections.values(): section.hide() section.flowbox.invalidate_filter() self._switch_stack() def _clear_sections(self): for section in self._sections.values(): section.clear_emojis() def _switch_stack(self): for section in self._sections.values(): if section.is_visible(): self._stack.set_visible_child_name('list') return self._stack.set_visible_child_name('not-found') def _get_current_theme(self): theme = app.config.get('emoticons_theme') themes = helpers.get_available_emoticon_themes() if theme not in themes: if sys.platform not in ('win32', 'darwin'): app.config.set('emoticons_theme', 'font') theme = 'font' else: # Win/Mac fallback to noto app.config.set('emoticons_theme', 'noto') theme = 'noto' return theme @staticmethod def _get_emoji_theme_path(theme): if theme == 'font': return 'font' emoticons_data_path = os.path.join(configpaths.get('EMOTICONS'), theme, '%s.png' % theme) if os.path.exists(emoticons_data_path): return emoticons_data_path emoticons_user_path = os.path.join(configpaths.get('MY_EMOTS'), '%s.png' % theme) if os.path.exists(emoticons_user_path): return emoticons_user_path log.warning('Could not find emoji theme: %s', theme) def load(self): theme = self._get_current_theme() path = self._get_emoji_theme_path(theme) if not theme or path is None: self._clear_sections() emoji_pixbufs.clear() return # Attach pixbuf generator pixbuf_generator = None if theme != 'font': pixbuf_generator = self._get_next_pixbuf(path) for section in self._sections.values(): section.pixbuf_generator = pixbuf_generator if self._load_source_id is not None: GLib.source_remove(self._load_source_id) # When we change emoji theme self._clear_sections() emoji_pixbufs.clear() factory = self._emoji_factory(theme == 'font') self._load_source_id = GLib.idle_add(lambda: next(factory, False), priority=GLib.PRIORITY_LOW) def _emoji_factory(self, font): for codepoint, attrs in emoji_data.items(): if not attrs['fully-qualified']: # We dont add these to the UI continue if font and not self._font_supports_codepoint(codepoint): continue section = self._sections[attrs['group']] yield section.add_emoji(codepoint, attrs) self._load_source_id = None emoji_pixbufs.complete = True def _font_supports_codepoint(self, codepoint): self._pango_layout.set_text(codepoint, -1) if self._pango_layout.get_unknown_glyphs_count(): return False if len(codepoint) > 1: # The font supports each of the codepoints # Check if the rendered glyph is more than one char if self._pango_layout.get_size()[0] > 19000: return False return True def _get_next_pixbuf(self, path): src_x = src_y = cur_column = 0 atlas = GdkPixbuf.Pixbuf.new_from_file(path) while True: src_x = cur_column * Emoji.PARSE_WIDTH subpixbuf = atlas.new_subpixbuf(src_x, src_y, Emoji.PARSE_WIDTH, Emoji.PARSE_HEIGHT) if list(subpixbuf.get_pixels())[0:4] == [0, 0, 0, 255]: # top left corner is a black pixel means image is missing subpixbuf = None if cur_column == Emoji.PARSE_COLUMNS - 1: src_y += Emoji.PARSE_WIDTH cur_column = 0 else: cur_column += 1 yield subpixbuf def _on_emoticon_press(self, flowbox, child): GLib.timeout_add(100, flowbox.unselect_child, child) codepoint, pixbuf = child.get_emoji() self._text_widget().insert_emoji(codepoint, pixbuf) def do_destroy(self): # Remove the references we hold to other objects self._text_widget = None # Never destroy, creating a new EmoticonPopover is expensive return True emoji_chooser = EmojiChooser()