diff --git a/efck/config.py b/efck/config.py index 450f491..20ed61c 100644 --- a/efck/config.py +++ b/efck/config.py @@ -3,8 +3,6 @@ import os from pathlib import Path -from .tabs import EmojiTab - logger = logging.getLogger(__name__) _skin_tone = { @@ -39,9 +37,12 @@ 'window_geometry': [360, 400], 'zoom': 100, 'force_clipboard': False, - EmojiTab.__name__: _emoji_filters, + 'EmojiTab': _emoji_filters, } +# Global recent emojis list +recent_emojis = [] + def load_config(): from . import CONFIG_DIRS @@ -65,6 +66,8 @@ def load_config(): else: logger.info('No prior config file. Will use built-in defaults.') config_state.update(obj or {}) + global recent_emojis + recent_emojis[:] = load_recent_emojis() def dump_config(): @@ -86,3 +89,40 @@ def dump_config(): continue logger.info('Config dumped %ssuccessfully to "%s"', "UN" if not success else "", fd.name) return success and fd.name + + +def load_recent_emojis(): + from . import CONFIG_DIRS + + for dir_path in CONFIG_DIRS: + file_path = Path(dir_path) / 'recent_emojis.json' + try: + with open(file_path, encoding='utf-8') as fd: + try: + return json.load(fd) + except json.JSONDecodeError as e: + logger.warning('Error decoding recent emojis JSON: %s', e) + except OSError: + continue + return [] + + +def dump_recent_emojis(recent_emojis): + from . import CONFIG_DIRS + + success = False + for dir_path in CONFIG_DIRS: + try: + os.makedirs(dir_path, exist_ok=True) + with open(Path(dir_path) / 'recent_emojis.json', 'w', encoding='utf-8') as fd: + try: + json.dump(recent_emojis, fd, indent=2) + success = True + break + except Exception as e: + logger.warning('Error dumping recent emojis: %s', e) + except OSError as e: + logger.info('Error opening recent emojis file for writing: %s', e) + continue + logger.info('Recent emojis dumped %ssuccessfully', "UN" if not success else "") + return success diff --git a/efck/tabs/emoji.py b/efck/tabs/emoji.py index f016dd0..b0d2fb8 100644 --- a/efck/tabs/emoji.py +++ b/efck/tabs/emoji.py @@ -33,7 +33,14 @@ class EmojiTab(Tab): def activated(self, force_clipboard, **kwargs): text = self.view.currentIndex().data() + from ..config import recent_emojis, dump_recent_emojis + if text in recent_emojis: + recent_emojis.remove(text) + recent_emojis.insert(0, text) + recent_emojis[:] = recent_emojis[:50] # keep only 50 + dump_recent_emojis(recent_emojis) type_chars(text, force_clipboard) + return False class ListModel(QAbstractListModel): def __init__(self, *args, **kwargs): diff --git a/efck/tabs/recent.py b/efck/tabs/recent.py new file mode 100644 index 0000000..f0fd03e --- /dev/null +++ b/efck/tabs/recent.py @@ -0,0 +1,212 @@ +import logging +import os +import re +from functools import lru_cache +from pathlib import Path + +from .. import IS_MACOS, IS_WIDOWS +from ..qt import * +from ..tab import Tab +from ..config import recent_emojis + +logger = logging.getLogger(__name__) + + +# Tabs are alphabetically sorted by classname, so this puts +# it after Emoji but before Filters. +class FRecentTab(Tab): + label = '&Recent' + icon = QIcon() # TODO: add an icon + line_edit_kwargs = dict( + placeholderText='Filter recent emoji ...', + ) + list_view_kwargs = dict( + flow=QListView.Flow.LeftToRight, + isWrapping=True, + wordWrap=True, + verticalScrollBarPolicy=Qt.ScrollBarPolicy.ScrollBarAlwaysOn, + ) + # Left/Right keys move the list view item selection + line_edit_ignore_keys = {Qt.Key.Key_Left, Qt.Key.Key_Right} | Tab.line_edit_ignore_keys + + def activated(self, force_clipboard, **kwargs): + from ..output import type_chars + from ..config import recent_emojis, dump_recent_emojis + text = self.view.currentIndex().data() + if text in recent_emojis: + recent_emojis.remove(text) + recent_emojis.insert(0, text) + recent_emojis[:] = recent_emojis[:50] # keep only 50 + dump_recent_emojis(recent_emojis) + # Update the view + self.model.beginResetModel() + self.model.endResetModel() + type_chars(text, force_clipboard) + return False + + class ListModel(QAbstractListModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.emoji_data = () + self.emoji_metadata = {} # Map emoji -> (name, alt_name, shortcode, custom_str) + + def init(self): + logger.info('Reloading recent emoji ...') + # Build metadata lookup from enum_emojis + from ..emoji import enum_emojis + for emoji_tuple in enum_emojis(): + emoji_char = emoji_tuple[0] + metadata = emoji_tuple[1:] # (name, alt_name, shortcode, custom_str) + self.emoji_metadata[emoji_char] = metadata + self.emoji_data = recent_emojis + + def rowCount(self, index): + return len(self.emoji_data) + + def data(self, index, role): + if role == Qt.ItemDataRole.DisplayRole: + emoji = self.emoji_data[index.row()] + return emoji + if role == Qt.ItemDataRole.UserRole: + return (self.emoji_data[index.row()],) # tuple for consistency + if role == Qt.ItemDataRole.ToolTipRole: + return self.emoji_data[index.row()] + + class Model(QSortFilterProxyModel): + filter_words = () + + def init(self, **kwargs): + self._model.init() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._model = FRecentTab.ListModel(parent=self) + self.setSourceModel(self._model) + + def set_text(self, text): + self.filter_words = text.lower().split() + self.invalidateFilter() + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + if not self.filter_words: + return True + emoji = self._model.emoji_data[source_row] + # Get metadata for this emoji (name, alt_name, shortcode, custom_str) + metadata = self._model.emoji_metadata.get(emoji, ()) + # Filter by checking if all filter words appear in any of the metadata strings + return bool(self.first_matching_string(metadata, self.filter_words)) + + @staticmethod + def first_matching_string(strings, words) -> str: + # First of the strings (name, alt_name, shortcode, custom_str) that contains + # ALL space-separated parts in any order + return next((s for s in strings + if all(w in s for w in words)), None) + + def beginResetModel(self): + super().beginResetModel() + self._model.beginResetModel() + + def endResetModel(self): + super().endResetModel() + self._model.endResetModel() + self.invalidateFilter() + + class Delegate(QStyledItemDelegate): + GRID_CELL_SIZE_PX = 64 + TEXT_FONT_SIZE_PX = 8 + ICON_FONT_SIZE = GRID_CELL_SIZE_PX - 16 + GRID_SIZE = QSize(GRID_CELL_SIZE_PX, GRID_CELL_SIZE_PX) + TEXT_FONT = QFont() + ALL_FONT_FAMILIES = [ + 'Noto Color Emoji', + "Twitter Color Emoji", + 'Apple Color Emoji', + 'Segoe UI Emoji', + "Joypixels", + "Symbola", + ] + _TEXT_OFFSET = 0 + _ICON_OFFSET = 0 + if IS_MACOS: + ICON_FONT_FAMILY = 'Apple Color Emoji' + _ICON_OFFSET = -5 + elif IS_WIDOWS: + ICON_FONT_FAMILY = 'Segoe UI Emoji' + TEXT_FONT.setFamily('Arial') + ICON_FONT_SIZE -= 3 + _TEXT_OFFSET = 5 + _ICON_OFFSET = -7 + TEXT_FONT_SIZE_PX = 9 + else: + ICON_FONT_FAMILY = 'Noto Color Emoji' + TEXT_OFFSET = _TEXT_OFFSET + ICON_OFFSET = QPoint(0, _ICON_OFFSET) + + _font_file = Path(__file__).parent.parent / 'NotoColorEmoji.ttf' + if _font_file.is_file(): + logger.info('Loading vendored font NotoColorEmoji.ttf') + _res = QFontDatabase.addApplicationFont(str(_font_file)) + if _res == -1: + logger.error('Error loading vendored font.') + + ICON_FONT_FAMILY = os.environ.get('ICON_FONT', ICON_FONT_FAMILY) + + ICON_FONT = QFont() + ICON_FONT.setFamilies([ICON_FONT_FAMILY, *ALL_FONT_FAMILIES]) + + filter_words = () + highlight_words = lambda _, text: text + + def set_text(self, text): + self.filter_words = words = text.lower().split() + find_words_re = re.compile(fr'({"|".join(map(re.escape, words))})', flags=re.I) + self.highlight_words = lambda text: find_words_re.sub(r'\1', text) + + def init(self, *, config, zoom, **kwargs): + assert .5 < zoom <= 2 + self.GRID_SIZE.scale(int(round(self.GRID_CELL_SIZE_PX * zoom)), + int(round(self.GRID_CELL_SIZE_PX * zoom)), + Qt.AspectRatioMode.IgnoreAspectRatio) + self.ICON_FONT.setPixelSize(int(round(self.ICON_FONT_SIZE * zoom))) + self.ICON_OFFSET = zoom * QPoint(0, self._ICON_OFFSET) + + self._StaticText.cache_clear() + + # Make sure the view calls sizeHint() again + self.parent().view.reset() + + def sizeHint(self, option, index): + return self.GRID_SIZE + + @lru_cache(5000) + def _StaticText(self, text): + s = QStaticText(text) + s.setTextFormat(Qt.TextFormat.RichText if '<' in text else Qt.TextFormat.PlainText) + s.setPerformanceHint(QStaticText.PerformanceHint.AggressiveCaching) + s.setTextWidth(self.GRID_SIZE.width()) + s.setTextOption(QTextOption(Qt.AlignmentFlag.AlignHCenter)) + return s + + def paint(self, painter: QPainter, option, index: QModelIndex): + self.initStyleOption(option, index) + painter.save() + if option.state & QStyle.StateFlag.State_Selected: + painter.fillRect(option.rect, option.palette.highlight()) + painter.setPen(option.palette.color(QPalette.ColorRole.HighlightedText)) + + emoji = index.data(Qt.ItemDataRole.UserRole)[0] + + text = self._StaticText(emoji) + painter.setFont(self.ICON_FONT) + painter.drawStaticText(option.rect.topLeft() + self.ICON_OFFSET, text) + + # No text below for recent, just the emoji + + painter.restore() + + class Options(QWidget): + def __init__(self, *args, config, **kwargs): + super().__init__(*args, **kwargs) + # No options for recent tab + self.setLayout(QVBoxLayout(self)) diff --git a/efck/tests.py b/efck/tests.py index e4715de..7abac1c 100644 --- a/efck/tests.py +++ b/efck/tests.py @@ -219,5 +219,74 @@ def test_filters(self): self.assertTrue(out, out) +class TestRecent(TestCase): + def test_recent_tab_model(self): + from .tabs.recent import FRecentTab + from .config import recent_emojis + + # Add some test emojis to recent list + test_emojis = ['🍍', '🥑', '🍑'] + recent_emojis[:] = test_emojis + + # Create and initialize the model + model = FRecentTab.ListModel() + model.init() + + # Test that emoji_data is populated + self.assertEqual(list(model.emoji_data), test_emojis) + + # Test that metadata dict is populated + self.assertGreater(len(model.emoji_metadata), 1000) + # Check that our test emojis have metadata + for emoji in test_emojis: + self.assertIn(emoji, model.emoji_metadata) + metadata = model.emoji_metadata[emoji] + self.assertIsInstance(metadata, tuple) + self.assertEqual(len(metadata), 4) # (name, alt_name, shortcode, custom_str) + + # Test rowCount + self.assertEqual(model.rowCount(None), len(test_emojis)) + + # Test data retrieval + index = model.index(0, 0) + display_data = model.data(index, Qt.ItemDataRole.DisplayRole) + self.assertEqual(display_data, test_emojis[0]) + user_data = model.data(index, Qt.ItemDataRole.UserRole) + self.assertEqual(user_data, (test_emojis[0],)) + + def test_recent_tab_filter(self): + from .tabs.recent import FRecentTab + from .config import recent_emojis + + # Add test emojis to recent list + recent_emojis[:] = ['🍍', '🥑', '🍑'] # pineapple, avocado, peach + + # Create and initialize the model + proxy_model = FRecentTab.Model() + proxy_model.init() + + # Test no filter - all items should be visible + self.assertEqual(proxy_model.rowCount(QModelIndex()), 3) + + # Test filter by 'avocado' - should match 🥑 + proxy_model.set_text('avocado') + count = proxy_model.rowCount(QModelIndex()) + self.assertGreater(count, 0, "Filter for 'avocado' should match at least one emoji") + + # Test filter by 'pineapple' - should match 🍍 + proxy_model.set_text('pineapple') + count = proxy_model.rowCount(QModelIndex()) + self.assertGreater(count, 0, "Filter for 'pineapple' should match at least one emoji") + + # Test filter by nonsense - should match nothing + proxy_model.set_text('xyzabc123notanemoji') + count = proxy_model.rowCount(QModelIndex()) + self.assertEqual(count, 0, "Filter for nonsense should match no emojis") + + # Test clearing filter + proxy_model.set_text('') + self.assertEqual(proxy_model.rowCount(QModelIndex()), 3) + + if __name__ == '__main__': unittest.main()