417 lines
14 KiB
Python
417 lines
14 KiB
Python
# 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/>.
|
|
|
|
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 codepoint, attrs in variations.items():
|
|
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()
|