Search every emoji by name in the reaction dialog
CI / lint (push) Canceled after 0s
CI / test macos-latest py3.14 (push) Canceled after 0s
CI / test ubuntu-24.04 py3.10 (push) Canceled after 0s
CI / test ubuntu-latest py3.11 (push) Canceled after 0s
CI / test ubuntu-latest py3.12 (push) Canceled after 0s
CI / test ubuntu-latest py3.13 (push) Canceled after 0s
CI / test ubuntu-latest py3.14 (push) Canceled after 0s
CI / test windows-latest py3.10 (push) Canceled after 0s
CI / test windows-latest py3.14 (push) Canceled after 0s
CI / build (push) Canceled after 0s
Issue trailers / check (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / test macos-latest py3.14 (push) Canceled after 0s
CI / test ubuntu-24.04 py3.10 (push) Canceled after 0s
CI / test ubuntu-latest py3.11 (push) Canceled after 0s
CI / test ubuntu-latest py3.12 (push) Canceled after 0s
CI / test ubuntu-latest py3.13 (push) Canceled after 0s
CI / test ubuntu-latest py3.14 (push) Canceled after 0s
CI / test windows-latest py3.10 (push) Canceled after 0s
CI / test windows-latest py3.14 (push) Canceled after 0s
CI / build (push) Canceled after 0s
Issue trailers / check (push) Canceled after 0s
^E still lists the reaction_emoji preference first, under "Your reactions". Under "All emoji" it now lists each other emoji with its Unicode name, and the list filters as you type: "fire" finds 🔥, "heart" finds the hearts. The catalog comes from the Unicode database of the Python that runs MeshTerm and from Rich's table of text-default emoji, so it needs no data file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3aa8e5aef
commit
d9426166c8
2 files changed
+92
-12
No files matched your search
+65
-11
@@ -18,7 +18,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import unicodedata
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from rich.cells import cell_len
|
||||
@@ -1562,12 +1564,14 @@ class ChatScreen(Screen):
|
||||
def _begin_react(self) -> None:
|
||||
"""Ask which emoji, then send the reaction to the selected message (^E).
|
||||
|
||||
The dialog is a question, so it floats as a box over the chat. Each row is an emoji
|
||||
of the ``reaction_emoji`` preference, with its name. A row that our node already sent
|
||||
to this message says so. A second copy of it would only count one time, so the
|
||||
selection of that row sends nothing. The reaction goes out as a usual message in
|
||||
the conversation. When it is stored, the fold puts it under its target (refer to
|
||||
:meth:`_admit`).
|
||||
The dialog is a question, so it floats as a box over the chat. The emoji of the
|
||||
``reaction_emoji`` preference come first, then each other emoji (refer to
|
||||
:func:`emoji_catalog`). Each row has the name of its emoji, so a typed word such as
|
||||
``fire`` or ``heart`` filters the list to the emoji that it names. A row that our
|
||||
node already sent to this message says so. A second copy of it would only count one
|
||||
time, so the selection of that row sends nothing. The reaction goes out as a usual
|
||||
message in the conversation. When it is stored, the fold puts it under its target
|
||||
(refer to :meth:`_admit`).
|
||||
"""
|
||||
target = self._react_target()
|
||||
if target is None or self._sending or self._react_open or self._fold is None:
|
||||
@@ -1579,16 +1583,25 @@ class ChatScreen(Screen):
|
||||
who = "your message" if message.outbound else sender
|
||||
|
||||
async def run() -> None:
|
||||
from .menus import menu_rows
|
||||
from .menus import menu_rows, section_heading
|
||||
|
||||
rows = []
|
||||
for emoji in self._reaction_set:
|
||||
def row(emoji: str) -> tuple[str, str, str]:
|
||||
name = reactions.emoji_name(emoji)
|
||||
sent = fold.reacted(message, emoji)
|
||||
rows.append((emoticon(emoji), f"{name} · sent" if sent else name, emoji))
|
||||
return emoticon(emoji), f"{name} · sent" if sent else name, emoji
|
||||
|
||||
mine = list(self._reaction_set)
|
||||
others = [emoji for emoji in emoji_catalog() if emoji not in mine]
|
||||
rows = menu_rows([row(emoji) for emoji in mine + others])
|
||||
items = [
|
||||
section_heading("Your reactions"),
|
||||
*rows[: len(mine)],
|
||||
section_heading("All emoji"),
|
||||
*rows[len(mine) :],
|
||||
]
|
||||
try:
|
||||
emoji = await self._session.select(
|
||||
f"React — {who}", menu_rows(rows), filterable=False, floating=True
|
||||
f"React — {who}", items, filterable=True, floating=True
|
||||
)
|
||||
finally:
|
||||
self._react_open = False
|
||||
@@ -1630,6 +1643,47 @@ class ChatScreen(Screen):
|
||||
pass
|
||||
|
||||
|
||||
#: The blocks of Unicode where an emoji with the emoji presentation can be. A wide symbol
|
||||
#: between them is a CJK symbol or radical, not an emoji.
|
||||
_EMOJI_BLOCKS = ((0x2000, 0x2E7F), (0x1F000, 0x1FAFF))
|
||||
|
||||
#: The I Ching symbols, which Unicode 16 draws wide. They are symbols, not emoji: the
|
||||
#: firmware and the other apps never offer them.
|
||||
_NOT_EMOJI = frozenset(range(0x268A, 0x2690)) | frozenset(range(0x2630, 0x2638))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def emoji_catalog() -> tuple[str, ...]:
|
||||
"""Each single emoji that the reaction dialog offers, in the order of Unicode.
|
||||
|
||||
The standard library has no list of emoji. Thus the catalog takes two sets. The first is
|
||||
each symbol in the emoji blocks that Unicode draws wide by default (``🔥``, ``⌚``). The
|
||||
second is the emoji that are text by default (``❤``), which the table of Rich lists.
|
||||
Each of these gets the variation selector that asks for the emoji form (``❤️``), as the
|
||||
apps send it. A flag, a skin tone, and a joined sequence are not in the catalog, but a
|
||||
reaction with one still shows when it arrives. The names come from the Unicode database
|
||||
of the Python that runs MeshTerm, so a newer Python offers newer emoji.
|
||||
"""
|
||||
from rich._unicode_data import load
|
||||
|
||||
found: dict[int, str] = {}
|
||||
for first, last in _EMOJI_BLOCKS:
|
||||
for cp in range(first, last + 1):
|
||||
char = chr(cp)
|
||||
if cp in _NOT_EMOJI:
|
||||
continue
|
||||
if unicodedata.category(char) == "So" and unicodedata.east_asian_width(char) == "W":
|
||||
found[cp] = char
|
||||
for char in load("auto").narrow_to_wide:
|
||||
if not char.isascii() and len(char) == 1:
|
||||
found.setdefault(ord(char), char + "\ufe0f")
|
||||
return tuple(
|
||||
emoji
|
||||
for _, emoji in sorted(found.items())
|
||||
if not reactions._is_regional(emoji[0]) and reactions.is_reaction_emoji(emoji)
|
||||
)
|
||||
|
||||
|
||||
async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
|
||||
"""Open the live chat screen for ``conversation`` and run it until the user closes it.
|
||||
|
||||
|
||||
+27
-1
@@ -316,12 +316,15 @@ class _Session:
|
||||
def __init__(self, answer: str | None) -> None:
|
||||
self.answer = answer
|
||||
self.asked: list[str] = []
|
||||
self.items: list = []
|
||||
self.filterable: bool | None = None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
pass
|
||||
|
||||
async def select(self, title, items, **_kw): # noqa: ANN001, ANN003, ANN201
|
||||
async def select(self, title, items, *, filterable=True, **_kw): # noqa: ANN001, ANN003, ANN201
|
||||
self.asked.append(title)
|
||||
self.items, self.filterable = items, filterable
|
||||
return self.answer
|
||||
|
||||
|
||||
@@ -408,3 +411,26 @@ def test_a_reaction_from_a_node_with_a_long_name_folds() -> None:
|
||||
assert fold.take(react) is True
|
||||
(badge,) = fold.badges(target)
|
||||
assert badge.emoji == "🔥" and badge.reactors == [reactions.Reactor("Hazelwood r0b3.Anduril")]
|
||||
|
||||
|
||||
async def test_the_reaction_dialog_searches_every_emoji_by_name() -> None:
|
||||
"""The preferred emoji come first, then each other emoji. A typed name filters them all."""
|
||||
from meshterm.ui.chat import emoji_catalog
|
||||
from meshterm.ui.tui import Choice, Separator
|
||||
|
||||
session = _Session(None)
|
||||
screen = _screen([_channel("Alice: hi all")], session, [])
|
||||
screen.handle("up")
|
||||
screen.handle("react")
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.gather(*screen._tasks)
|
||||
assert session.filterable is True
|
||||
values = [item.value for item in session.items if isinstance(item, Choice)]
|
||||
assert values[:6] == reactions.DEFAULT_REACTIONS.split()
|
||||
assert "🔥" in values and values.count("👍") == 1 # a preferred emoji is listed once
|
||||
assert isinstance(session.items[0], Separator) # "Your reactions", then "All emoji"
|
||||
assert sum(isinstance(item, Separator) for item in session.items) == 2
|
||||
fire = next(i for i in session.items if isinstance(i, Choice) and i.value == "🔥")
|
||||
assert "fire" in fire.label.plain
|
||||
assert len(emoji_catalog()) > 1000
|
||||
assert all(reactions.is_reaction_emoji(emoji) for emoji in emoji_catalog())
|
||||
Reference in new issue
Block a user