Skip to content
Snippets Groups Projects
Commit 92e82516 authored by Philipp Hörist's avatar Philipp Hörist Committed by mesonium
Browse files

fix: More Completion improvements

parent c300d816
No related branches found
No related tags found
No related merge requests found
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk" version="4.0"/>
<template class="EmojiCompletionViewItem" parent="GtkStack">
<child>
<object class="GtkStackPage">
<property name="name">emoji</property>
<property name="child">
<object class="GtkBox">
<property name="spacing">6</property>
<child>
<object class="GtkLabel" id="_emoji_label">
</object>
</child>
<child>
<object class="GtkLabel" id="_short_name_label">
</object>
</child>
<child>
<object class="GtkLabel" id="_keywords_label">
</object>
</child>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">variations</property>
<property name="child">
<object class="GtkBox">
<property name="spacing">6</property>
<child>
<object class="GtkButton" id="_var1_button">
<property name="label"></property>
</object>
</child>
<child>
<object class="GtkButton" id="_var2_button">
<property name="label"></property>
</object>
</child>
<child>
<object class="GtkButton" id="_var3_button">
<property name="label"></property>
</object>
</child>
<child>
<object class="GtkButton" id="_var4_button">
<property name="label"></property>
</object>
</child>
<child>
<object class="GtkButton" id="_var5_button">
<property name="label"></property>
</object>
</child>
</object>
</property>
</object>
</child>
</template>
</interface>
......@@ -76,7 +76,7 @@ class CommandsCompletionProvider(BaseCompletionProvider):
def __init__(self) -> None:
self._list_store = Gio.ListStore(item_type=CommandsCompletionListItem)
for command, usage in app.commands.get_commands('chat'): # TODO
for command, usage in app.commands.get_commands('chat'): # TODO
self._list_store.append(
CommandsCompletionListItem(command=command, usage=f'/{command} {usage}')
)
......
......@@ -9,52 +9,239 @@
import logging
from gi.repository import Gdk
from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
from gajim.common import app
from gajim.common.configpaths import get_ui_path
from gajim.common.i18n import _
from gajim.common.i18n import get_default_lang
from gajim.common.i18n import get_short_lang_code
from gajim.gtk.completion.base import BaseCompletionListItem
from gajim.gtk.completion.base import BaseCompletionProvider
from gajim.gtk.completion.base import BaseCompletionViewItem
from gajim.gtk.emoji_data_gtk import get_emoji_data
from gajim.gtk.util import SignalManager
log = logging.getLogger('gajim.gtk.completion.emoji')
EMOJI_DATA_ENTRY_T = tuple[list[int], str, str, list[str], list[str], int]
MAX_COMPLETION_ENTRIES = 8
FALLBACK_LOCALE = 'en'
SKIN_TONE_MODIFIERS = (0x1F3FB, 0x1F3FC, 0x1F3FD, 0x1F3FE, 0x1F3FF)
def get_locale_fallbacks(desired: str) -> list[str]:
'''
Returns full list of locales to try loading emoji data in, in the order of
decreasing preference and specificity. E.g., ['de', 'en']
for desired == 'de'.
'''
result = [desired]
if FALLBACK_LOCALE not in result:
result.append(FALLBACK_LOCALE)
return result
def generate_unicode_sequence(c_sequence: list[int]) -> tuple[bool, str]:
'''
Generates a unicode sequence from a list of codepoints
'''
has_skin_tone_variation = False
u_sequence = ''
for codepoint in c_sequence:
if codepoint == 0:
has_skin_tone_variation = True
codepoint = 0xFE0F
if codepoint == 0x1F3FB:
has_skin_tone_variation = True
continue
u_sequence += chr(codepoint)
return has_skin_tone_variation, u_sequence
def generate_skin_tone_sequence(c_sequence: list[int], modifier: int) -> str:
'''
Replaces GTKs placeholder '0' for skin tone modifiers
with a given modifier
'''
u_sequence = ''
for codepoint in c_sequence:
if codepoint in (0, 0x1F3FB):
codepoint = modifier
u_sequence += chr(codepoint)
return u_sequence
def try_load_raw_emoji_data(locale: str) -> GLib.Bytes | None:
# Sources of emoji data can be found at:
# https://gitlab.gnome.org/GNOME/gtk/-/tree/main/gtk/emoji
emoji_data_resource = f'/org/gtk/libgtk/emoji/{locale}.data'
# some distribution do not register locale emoji resources, so let's do it
try:
res = Gio.resource_load(f'/usr/share/gtk-4.0/emoji/{locale}.gresource')
except GLib.Error:
pass
else:
Gio.resources_register(res)
try:
bytes_ = Gio.resources_lookup_data(
emoji_data_resource, Gio.ResourceLookupFlags.NONE
)
assert bytes_ is not None
log.info('Loaded emoji data resource for locale %s', locale)
return bytes_
except GLib.Error as error:
log.info('Loading emoji data resource for locale %s failed: %s', locale, error)
return None
def parse_emoji_data(bytes_data: GLib.Bytes, loc: str) -> Gio.ListStore:
variant = GLib.Variant.new_from_bytes(
# Reference for the data format:
# https://gitlab.gnome.org/GNOME/gtk/-/blob/main/gtk/emoji/convert-emoji.c#L25
GLib.VariantType('a(aussasasu)'),
bytes_data,
True,
)
iterable: list[EMOJI_DATA_ENTRY_T] = variant.unpack()
store = Gio.ListStore(item_type=EmojiCompletionListItem)
for (
c_sequence,
_short_name,
trans_short_name,
_keywords,
trans_keywords,
_group,
) in iterable:
# Example item:
# (
# [128515],
# 'grinning face with big eyes',
# 'grinsendes Gesicht mit großen Augen',
# ['face', 'mouth', 'open', 'smile'],
# ['gesicht', 'grinsendes gesicht mit großen augen', 'lol', 'lustig', 'lächeln'],
# 1
# )
# If '0' is in c_sequence its a placeholder for skin tone modifiers
has_skin_variation, u_sequence = generate_unicode_sequence(c_sequence)
keywords_string = ', '.join(trans_keywords)
u_mod_sequences: dict[str, str] = {}
if has_skin_variation:
for index, modifier in enumerate(SKIN_TONE_MODIFIERS, start=1):
u_mod_sequences[f'var{index}'] = generate_skin_tone_sequence(
c_sequence, modifier
)
item = EmojiCompletionListItem(
emoji=u_sequence,
short_name=trans_short_name,
keywords=f'[ {keywords_string} ]',
search=f'{trans_short_name}|{"|".join(trans_keywords)}',
has_skin_variation=has_skin_variation,
**u_mod_sequences,
)
store.append(item)
MAX_COMPLETION_ENTRIES = 6
return store
class EmojiCompletionListItem(BaseCompletionListItem, GObject.Object):
__gtype_name__ = "EmojiCompletionListItem"
emoji = GObject.Property(type=str)
short_name = GObject.Property(type=str)
keywords = GObject.Property(type=str)
search = GObject.Property(type=str)
has_skin_variation = GObject.Property(type=bool, default=False)
var1 = GObject.Property(type=str, default='')
var2 = GObject.Property(type=str, default='')
var3 = GObject.Property(type=str, default='')
var4 = GObject.Property(type=str, default='')
var5 = GObject.Property(type=str, default='')
def get_text(self) -> str:
return 'teststring'
def __init__(
self,
short_name: str,
) -> None:
GObject.Object.__init__(self, short_name=short_name)
return self.props.emoji
class EmojiCompletionViewItem(BaseCompletionViewItem[EmojiCompletionListItem], Gtk.Box):
@Gtk.Template(filename=get_ui_path('emoji_completion_view_item.ui'))
class EmojiCompletionViewItem(
BaseCompletionViewItem[EmojiCompletionListItem], Gtk.Stack, SignalManager
):
__gtype_name__ = "EmojiCompletionViewItem"
_emoji_label: Gtk.Label = Gtk.Template.Child()
_short_name_label: Gtk.Label = Gtk.Template.Child()
_keywords_label: Gtk.Label = Gtk.Template.Child()
_var1_button: Gtk.Button = Gtk.Template.Child()
_var2_button: Gtk.Button = Gtk.Template.Child()
_var3_button: Gtk.Button = Gtk.Template.Child()
_var4_button: Gtk.Button = Gtk.Template.Child()
_var5_button: Gtk.Button = Gtk.Template.Child()
has_skin_variation = GObject.Property(type=bool, default=False)
def __init__(self) -> None:
SignalManager.__init__(self)
BaseCompletionViewItem.__init__(self)
Gtk.Box.__init__(self)
Gtk.Stack.__init__(self)
self._label = Gtk.Label()
controller = Gtk.GestureClick(
button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE
)
self._connect(controller, 'pressed', self._on_button_press)
self.add_controller(controller)
self.append(self._label)
self._connect(self._var1_button, 'clicked', self._on_var_button_clicked)
self._connect(self._var2_button, 'clicked', self._on_var_button_clicked)
self._connect(self._var3_button, 'clicked', self._on_var_button_clicked)
self._connect(self._var4_button, 'clicked', self._on_var_button_clicked)
self._connect(self._var5_button, 'clicked', self._on_var_button_clicked)
def _on_button_press(
self,
_gesture_click: Gtk.GestureClick,
_n_press: int,
_x: float,
_y: float,
) -> None:
if not self.props.has_skin_variation:
return Gdk.EVENT_PROPAGATE
self.set_visible_child_name('variations')
return Gdk.EVENT_STOP
def _on_var_button_clicked(self, button: Gtk.Button) -> None:
emoji = button.get_label()
view = self.get_parent().get_parent()
view.emit('extended-activate', emoji)
def bind(self, obj: EmojiCompletionListItem) -> None:
bind_spec = [
('short_name', self._label, 'label'),
('has_skin_variation', self, 'has_skin_variation'),
('emoji', self._emoji_label, 'label'),
('short_name', self._short_name_label, 'label'),
('keywords', self._keywords_label, 'label'),
('var1', self._var1_button, 'label'),
('var2', self._var2_button, 'label'),
('var3', self._var3_button, 'label'),
('var4', self._var4_button, 'label'),
('var5', self._var5_button, 'label'),
]
for source_prop, widget, target_prop in bind_spec:
......@@ -64,12 +251,14 @@ def bind(self, obj: EmojiCompletionListItem) -> None:
self._bindings.append(bind)
def unbind(self) -> None:
self.set_visible_child_name('emoji')
for bind in self._bindings:
bind.unbind()
self._bindings.clear()
def do_unroot(self) -> None:
Gtk.Box.do_unroot(self)
self._disconnect_all()
Gtk.Stack.do_unroot(self)
app.check_finalize(self)
......@@ -78,13 +267,40 @@ class EmojiCompletionProvider(BaseCompletionProvider):
trigger_char: Final = ':'
def __init__(self) -> None:
self._model = Gio.ListStore(item_type=EmojiCompletionListItem)
self._load_complete = False
# Gtk.SliceListModel
expression = Gtk.PropertyExpression.new(EmojiCompletionListItem, None, 'search')
self._string_filter = Gtk.StringFilter(expression=expression)
self._filter_model = Gtk.FilterListModel(filter=self._string_filter)
self._model = Gtk.SliceListModel(
model=self._filter_model, size=MAX_COMPLETION_ENTRIES
)
def get_model(self) -> tuple[Gio.ListModel, Type[Gtk.Widget]]:
return self._model, EmojiCompletionViewItem
@staticmethod
def _load_emoji_data() -> Gio.ListStore:
app_locale = get_default_lang()
log.info('Loading emoji data; application locale is %s', app_locale)
short_locale = get_short_lang_code(app_locale)
locales = get_locale_fallbacks(short_locale)
try:
log.debug('Trying locales %s', locales)
raw_emoji_data: GLib.Bytes | None = None
for loc in locales:
raw_emoji_data = try_load_raw_emoji_data(loc)
if raw_emoji_data:
break
else:
raise RuntimeError(f'No resource could be loaded; tried {locales}')
return parse_emoji_data(raw_emoji_data, loc)
except Exception as err:
log.warning('Unable to load emoji data: %s', err)
return Gio.ListStore(item_type=EmojiCompletionListItem)
def check(self, candidate: str, start_iter: Gtk.TextIter) -> bool:
return candidate.startswith(self.trigger_char)
......@@ -99,39 +315,10 @@ def populate(self, candidate: str) -> bool:
if not app.settings.get('enable_emoji_shortcodes'):
return False
self._model.remove_all()
emoji_data = get_emoji_data()
sn_matches: dict[str, str] = {}
kw_matches: dict[str, str] = {}
for keyword, entries in emoji_data.items():
if not keyword.startswith(candidate):
continue
for short_name, emoji in entries.items():
label = f'{emoji} {short_name}'
if keyword == short_name:
# Replace a possible keyword match with the shortname match:
sn_matches[emoji] = label
if kw_matches.get(emoji) is not None:
del kw_matches[emoji]
else:
# Only add a keyword match if no shortname match:
if sn_matches.get(emoji) is None:
kw_matches[emoji] = f'{label} [{keyword}]'
log.debug(
'Found %d "%s…" emoji by short name, %d more by keyword',
len(sn_matches),
candidate,
len(kw_matches),
)
# Put all shortname matches before keyword matches:
for emoji, label in list(sn_matches.items()) + list(kw_matches.items()):
self._model.append(EmojiCompletionListItem(short_name=label))
if self._model.get_n_items() >= MAX_COMPLETION_ENTRIES:
break
if not self._load_complete:
model = self._load_emoji_data()
self._filter_model.set_model(model)
self._load_complete = True
return self._model.get_n_items() > 0
self._string_filter.set_search(candidate)
return True
......@@ -11,17 +11,13 @@
import logging
from gi.repository import Gdk
from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import GtkSource
from gajim.common import app
from gajim.common import types
from gajim.common.const import Direction
from gajim.common.modules.contacts import GroupchatContact
from gajim.gtk.completion.base import BaseCompletionListItem
from gajim.gtk.completion.base import BaseCompletionProvider
from gajim.gtk.completion.base import BaseCompletionViewItem
......@@ -34,7 +30,7 @@ class CompletionPopover(Gtk.Popover):
'completion-picked': (
GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
None,
(object,),
(str,),
)
}
......@@ -58,10 +54,11 @@ def __init__(self, message_input: GtkSource.View) -> None:
# self._nick_completion = GroupChatNickCompletion()
self._view = Gtk.ListView(
self._view = CompletionListView(
model=Gtk.SingleSelection(), single_click_activate=True
)
self._view.connect('activate', self._on_item_activated)
self._view.connect('extended-activate', self._on_extended_item_activated)
factory = Gtk.SignalListItemFactory()
factory.connect('setup', self._on_factory_setup)
......@@ -109,8 +106,14 @@ def _on_item_activated(self, list_view: Gtk.ListView, position: int) -> None:
self.popdown()
model = cast(Gtk.SingleSelection, list_view.get_model())
assert model is not None
item = model.get_item(position)
self.emit('completion-picked', item)
item = cast(BaseCompletionListItem, model.get_item(position))
self.emit('completion-picked', item.get_text())
def _on_extended_item_activated(
self, list_view: Gtk.ListView, complete_string: str
) -> None:
self.popdown()
self.emit('completion-picked', complete_string)
def _on_key_pressed(
self,
......@@ -175,3 +178,13 @@ def _select(self, direction: Direction) -> None:
return
model.set_selected(new_pos)
class CompletionListView(Gtk.ListView):
__gsignals__ = {
'extended-activate': (
GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
None,
(str,),
)
}
# This file is part of Gajim.
#
# SPDX-License-Identifier: GPL-3.0-only
from __future__ import annotations
import logging
from collections import defaultdict
from gi.repository import Gio
from gi.repository import GLib
from gajim.common.i18n import _
from gajim.common.i18n import get_default_lang
from gajim.common.i18n import get_short_lang_code
FALLBACK_LOCALE = 'en'
log = logging.getLogger('gajim.gtk.emoji_data_gtk')
REPLACEMENT_CHARACTER = 0xFFFD
SKIN_TONE_MODIFIERS = {
# The descriptions match the official short names, see:
# https://github.com/unicode-org/cldr/blob/main/common/annotations/en.xml
# Translators: Translations have to match https://github.com/milesj/emojibase/blob/master/packages/data/{LANG}/data.raw.json
# You can use the hex value to find the matching string.
_('light skin tone'): 0x1F3FB,
_('medium-light skin tone'): 0x1F3FC,
_('medium skin tone'): 0x1F3FD,
_('medium-dark skin tone'): 0x1F3FE,
_('dark skin tone'): 0x1F3FF
}
SKIN_TONE_MODIFIERS_FALLBACK = {
'light skin tone': 0x1F3FB,
'medium-light skin tone': 0x1F3FC,
'medium skin tone': 0x1F3FD,
'medium-dark skin tone': 0x1F3FE,
'dark skin tone': 0x1F3FF
}
def generate_unicode_sequence(c_sequence: list[int]) -> str:
'''
Generates a unicode sequence from a list of codepoints
'''
u_sequence = ''
for codepoint in c_sequence:
u_sequence += chr(codepoint)
return u_sequence
def replace_skin_tone_placeholder(c_sequence: list[int],
modifier: int
) -> list[int]:
'''
Replaces GTKs placeholder '0' for skin tone modifiers
with a given modifier
'''
c_mod_sequence: list[int] = []
for codepoint in c_sequence:
if codepoint == 0:
codepoint = modifier
c_mod_sequence.append(codepoint)
return c_mod_sequence
def get_emoji_data() -> dict[str, dict[str, str]]:
'''
Returns dict of `keyword` -> dict of `short_name` -> `emoji`, where
`keyword` and `short_name` are as defined in
<https://unicode.org/reports/tr35/tr35-general.html#Annotations>, and
`emoji` is an emoji grapheme cluster.
Short names are included among keywords.
'''
return emoji_data
def try_load_raw_emoji_data(locale: str) -> GLib.Bytes | None:
# Sources of emoji data can be found at:
# https://gitlab.gnome.org/GNOME/gtk/-/tree/main/gtk/emoji
emoji_data_resource = f'/org/gtk/libgtk/emoji/{locale}.data'
# some distribution do not register locale emoji resources, so let's do it
try:
res = Gio.resource_load(f'/usr/share/gtk-4.0/emoji/{locale}.gresource')
except GLib.Error:
pass
else:
Gio.resources_register(res)
try:
bytes_ = Gio.resources_lookup_data(
emoji_data_resource,
Gio.ResourceLookupFlags.NONE)
assert bytes_ is not None
log.info('Loaded emoji data resource for locale %s', locale)
return bytes_
except GLib.Error as error:
log.info('Loading emoji data resource for locale %s failed: %s',
locale, error)
return None
def parse_emoji_data(bytes_data: GLib.Bytes,
loc: str
) -> dict[str, dict[str, str]]:
variant = GLib.Variant.new_from_bytes(
# Reference for the data format:
# https://gitlab.gnome.org/GNOME/gtk/-/blob/main/gtk/emoji/convert-emoji.c
GLib.VariantType('a(aussasasu)'),
bytes_data,
True)
iterable: list[tuple[list[int], str, str, list[str], list[str], int]] = variant.unpack()
# GTK 3 provides emoji translations only for the following locales
if loc in ['de', 'es', 'fr', 'zh']:
skin_tone_modifiers = SKIN_TONE_MODIFIERS
else:
skin_tone_modifiers = SKIN_TONE_MODIFIERS_FALLBACK
emoji_data_dict: dict[str, dict[str, str]] = defaultdict(dict)
for c_sequence, _short_name, trans_short_name, _keywords, trans_keywords, _group in iterable:
# Example item:
# (
# [128515],
# 'grinning face with big eyes',
# 'grinsendes Gesicht mit großen Augen',
# ['face', 'mouth', 'open', 'smile'],
# ['gesicht', 'grinsendes gesicht mit großen augen', 'lol', 'lustig', 'lächeln'],
# 0
# )
# GTK sets '0' as a placeholder for skin tone modifiers
# Replace colon by comma to improve short name completion usability
short_name = trans_short_name.replace(':', ',')
for keyword in trans_keywords + [short_name]:
keyword = keyword.casefold()
if 0 not in c_sequence:
# No skin tone modifiers present
u_sequence = generate_unicode_sequence(c_sequence)
emoji_data_dict[keyword][short_name] = u_sequence
continue
# Filter out 0 in order to generate basic (yellow) variation
c_basic_sequence = [c for c in c_sequence if c != 0]
u_sequence = generate_unicode_sequence(c_basic_sequence)
emoji_data_dict[keyword][short_name] = u_sequence
# Add variations with skin tone modifiers
for mod_desc, modifier in skin_tone_modifiers.items():
new_keyword = f'{keyword}, {mod_desc.casefold()}'
new_short_name = f'{short_name}, {mod_desc}'
c_mod_sequence = replace_skin_tone_placeholder(
c_sequence, modifier)
u_mod_sequence = generate_unicode_sequence(c_mod_sequence)
emoji_data_dict[new_keyword][new_short_name] = u_mod_sequence
emoji_data_dict = dict(sorted(emoji_data_dict.items()))
for keyword, entries in emoji_data_dict.items():
emoji_data_dict[keyword] = dict(sorted(entries.items()))
return emoji_data_dict
def get_locale_fallbacks(desired: str) -> list[str]:
'''
Returns full list of locales to try loading emoji data in, in the order of
decreasing preference and specificity. E.g., ['de', 'en']
for desired == 'de'.
'''
result = [desired]
if FALLBACK_LOCALE not in result:
result.append(FALLBACK_LOCALE)
return result
app_locale = get_default_lang()
log.info('Loading emoji data; application locale is %s', app_locale)
short_locale = get_short_lang_code(app_locale)
locales = get_locale_fallbacks(short_locale)
try:
log.debug('Trying locales %s', locales)
raw_emoji_data: GLib.Bytes | None = None
for loc in locales:
raw_emoji_data = try_load_raw_emoji_data(loc)
if raw_emoji_data:
break
else:
raise RuntimeError(f'No resource could be loaded; tried {locales}')
emoji_data = parse_emoji_data(raw_emoji_data, loc)
except Exception as err:
log.warning('Unable to load emoji data: %s', err)
emoji_data = {}
......@@ -30,10 +30,9 @@
from gajim.common.styling import process
from gajim.common.types import ChatContactT
from gajim.gtk.completion.base import BaseCompletionListItem
from gajim.gtk.completion.commands import CommandsCompletionProvider
from gajim.gtk.completion.popover import CompletionPopover
from gajim.gtk.completion.emoji import EmojiCompletionProvider
from gajim.gtk.completion.popover import CompletionPopover
from gajim.gtk.const import MAX_MESSAGE_LENGTH
from gajim.gtk.menus import get_message_input_extra_context_menu
from gajim.gtk.util import scroll_to_end
......@@ -178,11 +177,11 @@ def _populate_completion(self, buf: Gtk.TextBuffer) -> bool:
return True
return False
def _on_completion_picked(self, popover: CompletionPopover, item: BaseCompletionListItem) -> None:
def _on_completion_picked(self, popover: CompletionPopover, complete_string: str) -> None:
buf = self.get_buffer()
start, end = self._get_completion_candidate_bounds(buf)
buf.delete(start, end)
buf.insert(start, item.get_text())
buf.insert(start, complete_string)
self.grab_focus()
def _on_focus_enter(self, _focus_controller: Gtk.EventControllerFocus) -> None:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment