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

^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:
richandClaude Opus 5.5 committed 2026-10-15 07:47:13 -04:00
1 parent f3aa8e5aef
commit d9426166c8
2 files changed
+92 -12

No files matched your search

+65 -11
View File
@@ -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
View File
@@ -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())