From bbf886690466d709769c7ff8ced477659e6a09ee Mon Sep 17 00:00:00 2001 From: redgreen Date: Mon, 3 Aug 2026 09:09:12 +0300 Subject: [PATCH 1/5] Display custom emoji icons and styles on inline keyboard buttons Renders InlineKeyboardButton.iconCustomEmojiId before the button text and maps ButtonStyle primary/success/danger to textNeutral/iconPositive/ textNegative theme colors (Bot API 9.4). Co-Authored-By: Claude Fable 5 --- .../component/chat/MessageView.java | 32 ++++ .../challegram/data/TGInlineKeyboard.java | 170 ++++++++++++++++-- .../thunderdog/challegram/data/TGMessage.java | 33 ++++ 3 files changed, 219 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/MessageView.java b/app/src/main/java/org/thunderdog/challegram/component/chat/MessageView.java index f130fc7425..473510516e 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/MessageView.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/MessageView.java @@ -111,6 +111,7 @@ public class MessageView extends SparseDrawableView implements Destroyable, Draw private final DoubleImageReceiver replyReceiver; private final RefreshRateLimiter refreshRateLimiter, highRefreshRateLimiter; private ComplexReceiver footerTextMediaReceiver; + private ComplexReceiver replyMarkupTextMediaReceiver; private ImageReceiver contentReceiver; private DoubleImageReceiver previewReceiver; @@ -194,6 +195,9 @@ public void performDestroy () { if (complexReceiver != null) { complexReceiver.performDestroy(); } + if (replyMarkupTextMediaReceiver != null) { + replyMarkupTextMediaReceiver.performDestroy(); + } if (msg != null) { msg.onDestroy(); } @@ -245,6 +249,15 @@ public void invalidateFooterTextMediaReceiver (@NonNull TGMessage msg, @NonNull invalidateTextMediaReceiver(msg, text, textMedia, getFooterTextMediaReceiver(true)); } + public void invalidateReplyMarkupTextMediaReceiver (@NonNull TGMessage msg, @NonNull Text text, @Nullable TextMedia textMedia) { + if (this.msg == msg) { + ComplexReceiver receiver = getReplyMarkupTextMediaReceiver(true); + if (!text.invalidateMediaContent(receiver, textMedia)) { + msg.requestReplyMarkupTextMedia(receiver); + } + } + } + private void invalidateTextMediaReceiver (@NonNull TGMessage msg, @NonNull Text text, @Nullable TextMedia textMedia, @NonNull ComplexReceiver receiver) { if (this.msg == msg) { if (!text.invalidateMediaContent(receiver, textMedia)) { @@ -482,6 +495,19 @@ public ComplexReceiver getFooterTextMediaReceiver (boolean force) { return footerTextMediaReceiver; } + public ComplexReceiver getReplyMarkupTextMediaReceiver (boolean force) { + if (replyMarkupTextMediaReceiver == null && force) { + replyMarkupTextMediaReceiver = new ComplexReceiver() + .setUpdateListener(refreshRateLimiter); + if (isAttached) { + replyMarkupTextMediaReceiver.attach(); + } else { + replyMarkupTextMediaReceiver.detach(); + } + } + return replyMarkupTextMediaReceiver; + } + private boolean isAttached = true; public void onAttachedToRecyclerView () { @@ -498,6 +524,9 @@ public void onAttachedToRecyclerView () { emojiStatusReceiver.attach(); replyReceiver.attach(); replyTextMediaReceiver.attach(); + if (replyMarkupTextMediaReceiver != null) { + replyMarkupTextMediaReceiver.attach(); + } if ((flags & FLAG_USE_COMMON_RECEIVER) != 0) { contentReceiver.attach(); previewReceiver.attach(); @@ -521,6 +550,9 @@ public void onDetachedFromRecyclerView () { emojiStatusReceiver.detach(); replyReceiver.detach(); replyTextMediaReceiver.detach(); + if (replyMarkupTextMediaReceiver != null) { + replyMarkupTextMediaReceiver.detach(); + } if ((flags & FLAG_USE_COMMON_RECEIVER) != 0) { contentReceiver.detach(); previewReceiver.detach(); diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java b/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java index 164e9c0b3d..24b4b456d6 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java @@ -38,6 +38,7 @@ import org.thunderdog.challegram.component.chat.MessageView; import org.thunderdog.challegram.core.Lang; import org.thunderdog.challegram.emoji.Emoji; +import org.thunderdog.challegram.loader.ComplexReceiver; import org.thunderdog.challegram.navigation.SettingsWrapBuilder; import org.thunderdog.challegram.navigation.TooltipOverlayView; import org.thunderdog.challegram.navigation.ViewController; @@ -54,6 +55,7 @@ import org.thunderdog.challegram.ui.MessagesController; import org.thunderdog.challegram.util.CustomTypefaceSpan; import org.thunderdog.challegram.util.DrawableProvider; +import org.thunderdog.challegram.util.EmojiStatusHelper; import org.thunderdog.challegram.util.EmojiString; import org.thunderdog.challegram.util.text.Text; import org.thunderdog.challegram.widget.CheckBoxView; @@ -104,6 +106,13 @@ public TGInlineKeyboard (@NonNull TGMessage parent, boolean owned) { public void clear () { this.keyboard = null; this.messageId = 0; + if (!buttons.isEmpty()) { + for (Button button : buttons) { + button.performDestroy(); + } + buttons.clear(); + parent.invalidateReplyMarkupTextMedia(); + } } public void updateMessageId (long oldMessageId, long newMessageId) { @@ -118,6 +127,7 @@ public void set (long replyMarkupMessageId, @NonNull TdApi.ReplyMarkupInlineKeyb int realMaxWidth = Math.max(contentWidth, findMaxColumnCount(keyboard.rows) * getSmallestDesiredWidth()); this.maxWidth = Math.min(contentMaxWidth, Math.max(context.useBubbles() ? Screen.dp(40f) : Screen.dp(200f), realMaxWidth)); buildLayout(maxWidth, contentMaxWidth); + parent.invalidateReplyMarkupTextMedia(); } private boolean isCustom, disableCustomPadding; @@ -163,6 +173,28 @@ public void setViewProvider (ViewProvider viewProvider) { } } + public boolean needTextMedia () { + for (Button button : buttons) { + if (button.hasIconTextMedia()) { + return true; + } + } + return false; + } + + public void requestTextMedia (ComplexReceiver receiver) { + for (int i = 0; i < buttons.size(); i++) { + buttons.get(i).requestIconTextMedia(receiver, i); + } + receiver.clearReceiversWithHigherKey(buttons.size()); + } + + public void performDestroy () { + for (Button button : buttons) { + button.performDestroy(); + } + } + private static int findMaxColumnCount (TdApi.InlineKeyboardButton[][] rows) { int max = -1; for (TdApi.InlineKeyboardButton[] row : rows) { @@ -236,17 +268,15 @@ private void buildLayout (int maxWidth, int retryWidth) { if (minWidth != 0) { preferredMinWidth = Math.max(preferredMinWidth, (minWidth + buttonPadding * 2) * row.length + buttonSpacing * (row.length - 1)); } - int minButtonWidth = button.wrapper.getMaxLineWidth() + buttonTextPadding * 2; + int minButtonWidth = button.getMinContentWidth() + buttonTextPadding * 2; if (buttonWidth < minButtonWidth) { preferredMinWidth = Math.max(preferredMinWidth, minButtonWidth * row.length + buttonSpacing * (row.length - 1)); } buttonCount++; } } - if (buttonCount < buttons.size() - 1) { - for (int i = buttonCount; i < buttons.size(); i++) { - buttons.remove(i); - } + while (buttons.size() > buttonCount) { + buttons.remove(buttons.size() - 1).performDestroy(); } if (retryWidth != 0 && retryWidth > maxWidth && preferredMinWidth > maxWidth) { @@ -438,6 +468,11 @@ public static class Button implements FactorAnimator.Target, DrawableProvider { private String currencyChar; private float currencyCharWidth; + private long iconCustomEmojiId; + private @Nullable Text iconText; + private @ColorId int styleColorId; + private int iconTextColor; + public Button (TGInlineKeyboard context, @NonNull TGMessage parent, TdApi.InlineKeyboardButton button, int maxWidth) { this.context = context; this.parent = parent; @@ -446,7 +481,12 @@ public Button (TGInlineKeyboard context, @NonNull TGMessage parent, TdApi.Inline String text = uppercase(cleanButtonText(button.text)); this.needFakeBold = Text.needFakeBold(text); TextPaint textPaint = Paints.getBoldPaint14(needFakeBold); - this.wrapper = new EmojiString(text, maxWidth, textPaint); + this.iconCustomEmojiId = button.iconCustomEmojiId; + if (button.iconCustomEmojiId != 0) { + this.iconText = buildIconText(button.iconCustomEmojiId); + } + this.styleColorId = resolveStyleColorId(button.style); + this.wrapper = new EmojiString(text, Math.max(0, maxWidth - getIconFootprint()), textPaint); this.type = button.type; if (type.getConstructor() == TdApi.InlineKeyboardButtonTypeBuy.CONSTRUCTOR) { currencyChar = CurrencyUtils.getCurrencyChar(((TdApi.MessageInvoice) parent.getMessage().content).currency); @@ -455,7 +495,71 @@ public Button (TGInlineKeyboard context, @NonNull TGMessage parent, TdApi.Inline } public float getPreferredMinWidth () { - return wrapper.getPreferredMinWidth(); + float minWidth = wrapper.getPreferredMinWidth(); + return minWidth != 0 ? minWidth + getIconFootprint() : 0; + } + + int getMinContentWidth () { + return wrapper.getMaxLineWidth() + getIconFootprint(); + } + + private int getIconFootprint () { + if (iconText == null) { + return 0; + } + int spacing = wrapper == null || !wrapper.getText().isEmpty() ? Screen.dp(ICON_SPACING_DP) : 0; + return iconText.getWidth() + spacing; + } + + boolean hasIconTextMedia () { + return iconText != null; + } + + void requestIconTextMedia (ComplexReceiver receiver, int index) { + if (iconText != null) { + iconText.requestMedia(receiver, index, 1); + } else { + receiver.clearReceivers(index); + } + } + + public void performDestroy () { + if (iconText != null) { + iconText.performDestroy(); + iconText = null; + } + } + + private Text buildIconText (long customEmojiId) { + TdApi.TextEntity iconEntity = new TdApi.TextEntity(0, 1, new TdApi.TextEntityTypeCustomEmoji(customEmojiId)); + TdApi.FormattedText formattedText = new TdApi.FormattedText(EmojiStatusHelper.EMOJI, new TdApi.TextEntity[] {iconEntity}); + return new Text.Builder(parent.tdlib(), formattedText, null, Screen.dp(1000f), Paints.robotoStyleProvider(BUTTON_TEXT_SIZE_DP), () -> iconTextColor, (text, specificMedia) -> parent.invalidateReplyMarkupTextMedia(text, specificMedia)) + .singleLine() + .build(); + } + + private static @ColorId int resolveStyleColorId (@Nullable TdApi.ButtonStyle style) { + if (style == null) { + return ColorId.NONE; + } + switch (style.getConstructor()) { + case TdApi.ButtonStyleDefault.CONSTRUCTOR: + return ColorId.NONE; + case TdApi.ButtonStylePrimary.CONSTRUCTOR: + return ColorId.textNeutral; + case TdApi.ButtonStyleSuccess.CONSTRUCTOR: + return ColorId.iconPositive; + case TdApi.ButtonStyleDanger.CONSTRUCTOR: + return ColorId.textNegative; + default: { + Td.assertButtonStyle_da99259d(); + throw Td.unsupported(style); + } + } + } + + private @ColorId int overrideColorId () { + return customColorId != ColorId.NONE ? customColorId : styleColorId; } public Button (TGInlineKeyboard context, @NonNull TGMessage parent, String text, @DrawableRes int iconRes, int maxWidth) { @@ -476,12 +580,24 @@ private String uppercase (String text) { public void set (TdApi.InlineKeyboardButton button, int maxWidth) { this.type = button.type; + this.styleColorId = resolveStyleColorId(button.style); + if (this.iconCustomEmojiId != button.iconCustomEmojiId) { + this.iconCustomEmojiId = button.iconCustomEmojiId; + if (iconText != null) { + iconText.performDestroy(); + iconText = null; + } + if (button.iconCustomEmojiId != 0) { + this.iconText = buildIconText(button.iconCustomEmojiId); + } + } String text = uppercase(cleanButtonText(button.text)); final boolean reset = !wrapper.getText().equals(text); - if (reset || wrapper.getMaxWidth() != maxWidth) { + final int textMaxWidth = Math.max(0, maxWidth - getIconFootprint()); + if (reset || wrapper.getMaxWidth() != textMaxWidth) { this.needFakeBold = Text.needFakeBold(text); TextPaint textPaint = Paints.getBoldPaint14(needFakeBold); - this.wrapper = new EmojiString(uppercase(text), maxWidth, textPaint); + this.wrapper = new EmojiString(uppercase(text), textMaxWidth, textPaint); } if (reset || !Td.equalsTo(type, button.type)) { if (contextId == Integer.MAX_VALUE) { @@ -517,6 +633,7 @@ private boolean useWhiteMode () { } private static final float CUSTOM_ICON_PADDING = 2f; + private static final float ICON_SPACING_DP = 4f; public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, int buttonHeight, int strokePadding, RectF rounder, int row, int column) { final int right = cx + buttonWidth; @@ -547,7 +664,8 @@ public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, i final boolean useBubbleMode = useWhiteMode(); // float darkFactor = Theme.getDarkFactor(); - int inlineOutlineColor = customColorId != ColorId.NONE ? Theme.getColor(customColorId) : Theme.inlineOutlineColor(isOutBubble); + final @ColorId int buttonColorId = overrideColorId(); + int inlineOutlineColor = buttonColorId != ColorId.NONE ? Theme.getColor(buttonColorId) : Theme.inlineOutlineColor(isOutBubble); int fillingColor = 0; if (useBubbleMode) { @@ -591,7 +709,9 @@ public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, i //noinspection ConstantConditions final float textColorFactor = ALLOW_INVERSE ? (selectionFactor * activeFactor * (1f - fadeFactor)) : ALLOW_ALWAYS_ACTIVE ? selectionFactor * (1f - fadeFactor) : 0f; - final int textColor = useBubbleMode ? context.context.getBubbleButtonTextColor() : ColorUtils.fromToArgb(customColorId != ColorId.NONE ? Theme.getColor(customColorId) :Theme.inlineTextColor(isOutBubble), Theme.inlineTextActiveColor(), textColorFactor); + final int textColor = useBubbleMode ? + (buttonColorId != ColorId.NONE ? Theme.getColor(buttonColorId) : context.context.getBubbleButtonTextColor()) : + ColorUtils.fromToArgb(buttonColorId != ColorId.NONE ? Theme.getColor(buttonColorId) : Theme.inlineTextColor(isOutBubble), Theme.inlineTextActiveColor(), textColorFactor); int textX = cx + getButtonPadding(); if (customIconRes != 0) { @@ -610,12 +730,29 @@ public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, i } Drawables.draw(c, drawable, iconX, cy + buttonHeight / 2 - drawable.getMinimumHeight() / 2, Paints.getPorterDuffPaint(textColor)); + } else if (iconText != null) { + int iconWidth = iconText.getWidth(); + int iconHeight = iconText.getHeight(); + int contentWidth = Math.min(wrapper.getMaxLineWidth(), wrapper.getWidth()); + int spacing = contentWidth > 0 ? Screen.dp(ICON_SPACING_DP) : 0; + int totalWidth = iconWidth + spacing + contentWidth; + int iconX, textLeft; + if (Lang.rtl()) { + iconX = cx + buttonWidth / 2 + totalWidth / 2 - iconWidth; + textLeft = cx + buttonWidth / 2 - totalWidth / 2; + } else { + iconX = cx + buttonWidth / 2 - totalWidth / 2; + textLeft = iconX + iconWidth + spacing; + } + textX = textLeft - (wrapper.getWidth() - contentWidth) / 2; + this.iconTextColor = textColor; + iconText.draw(c, iconX, cy + (buttonHeight - iconHeight) / 2, null, 1f, view.getReplyMarkupTextMediaReceiver(true)); } Paints.getBoldPaint14(needFakeBold, Theme.inlineTextColor(isOutBubble)); wrapper.draw(c, textX, cy + Screen.dp(12f), textColor, true); if (type != null) { - int iconColor = Theme.inlineIconColor(isOutBubble); + int iconColor = buttonColorId != ColorId.NONE ? Theme.getColor(buttonColorId) : Theme.inlineIconColor(isOutBubble); switch (type.getConstructor()) { case TdApi.InlineKeyboardButtonTypeSwitchInline.CONSTRUCTOR: case TdApi.InlineKeyboardButtonTypeCallbackWithPassword.CONSTRUCTOR: @@ -642,18 +779,18 @@ public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, i int padding = Screen.dp(paddingDp); Drawables.draw(c, icon, dirtyRect.right - icon.getMinimumWidth() - padding, dirtyRect.top + padding, useBubbleMode ? (progressFactor == 0f ? Paints.getInlineBubbleIconPaint(textColor) : Paints.getPorterDuffPaint(ColorUtils.alphaColor(1f - progressFactor, textColor))) : - textColorFactor == 0f && progressFactor == 0f ? Paints.getInlineIconPorterDuffPaint(isOutBubble) : Paints.getPorterDuffPaint(ColorUtils.alphaColor(1f - progressFactor, ColorUtils.fromToArgb(iconColor, Theme.inlineTextActiveColor(), textColorFactor)))); + textColorFactor == 0f && progressFactor == 0f && buttonColorId == ColorId.NONE ? Paints.getInlineIconPorterDuffPaint(isOutBubble) : Paints.getPorterDuffPaint(ColorUtils.alphaColor(1f - progressFactor, ColorUtils.fromToArgb(iconColor, Theme.inlineTextActiveColor(), textColorFactor)))); drawProgress(c, useBubbleMode, textColorFactor); break; } case TdApi.InlineKeyboardButtonTypeUrl.CONSTRUCTOR: { Drawable icon = getSparseDrawable(R.drawable.deproko_baseline_link_arrow_20, ColorId.NONE); - Drawables.draw(c, icon, dirtyRect.right - icon.getMinimumWidth(), dirtyRect.top, useBubbleMode ? Paints.getInlineBubbleIconPaint(textColor) : textColorFactor == 0f ? Paints.getInlineIconPorterDuffPaint(isOutBubble) : Paints.getPorterDuffPaint(ColorUtils.fromToArgb(iconColor, Theme.inlineTextActiveColor(), textColorFactor))); + Drawables.draw(c, icon, dirtyRect.right - icon.getMinimumWidth(), dirtyRect.top, useBubbleMode ? Paints.getInlineBubbleIconPaint(textColor) : textColorFactor == 0f && buttonColorId == ColorId.NONE ? Paints.getInlineIconPorterDuffPaint(isOutBubble) : Paints.getPorterDuffPaint(ColorUtils.fromToArgb(iconColor, Theme.inlineTextActiveColor(), textColorFactor))); break; } case TdApi.InlineKeyboardButtonTypeLoginUrl.CONSTRUCTOR: { Drawable icon = getSparseDrawable(R.drawable.deproko_baseline_link_arrow_20, ColorId.NONE); - Drawables.draw(c, icon, dirtyRect.right - icon.getMinimumWidth(), dirtyRect.top, useBubbleMode ? Paints.getInlineBubbleIconPaint(ColorUtils.alphaColor(1f - progressFactor, textColor)) : textColorFactor == 0f && progressFactor == 1f ? Paints.getInlineIconPorterDuffPaint(isOutBubble) : Paints.getPorterDuffPaint(ColorUtils.alphaColor(1f - progressFactor, ColorUtils.fromToArgb(iconColor, Theme.inlineTextActiveColor(), textColorFactor)))); + Drawables.draw(c, icon, dirtyRect.right - icon.getMinimumWidth(), dirtyRect.top, useBubbleMode ? Paints.getInlineBubbleIconPaint(ColorUtils.alphaColor(1f - progressFactor, textColor)) : textColorFactor == 0f && progressFactor == 1f && buttonColorId == ColorId.NONE ? Paints.getInlineIconPorterDuffPaint(isOutBubble) : Paints.getPorterDuffPaint(ColorUtils.alphaColor(1f - progressFactor, ColorUtils.fromToArgb(iconColor, Theme.inlineTextActiveColor(), textColorFactor)))); drawProgress(c, useBubbleMode, textColorFactor); break; } @@ -686,7 +823,8 @@ public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, i private void drawProgress (Canvas c, boolean useBubbleMode, float textColorFactor) { if (progress != null) { - final int color = useBubbleMode ? context.context.getBubbleButtonTextColor() : ColorUtils.fromToArgb(customColorId != ColorId.NONE ? Theme.getColor(customColorId) :Theme.inlineIconColor(context.context != null && context.context.isOutgoingBubble()), Theme.inlineTextActiveColor(), textColorFactor); + final @ColorId int buttonColorId = overrideColorId(); + final int color = useBubbleMode ? context.context.getBubbleButtonTextColor() : ColorUtils.fromToArgb(buttonColorId != ColorId.NONE ? Theme.getColor(buttonColorId) : Theme.inlineIconColor(context.context != null && context.context.isOutgoingBubble()), Theme.inlineTextActiveColor(), textColorFactor); final int progressColor = ColorUtils.color((int) ((float) Color.alpha(color) * progressFactor), color); progress.forceColor(progressColor); progress.draw(c); diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGMessage.java b/app/src/main/java/org/thunderdog/challegram/data/TGMessage.java index 9b6a89aa11..5478887513 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGMessage.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGMessage.java @@ -2836,6 +2836,20 @@ public final void invalidateReplyTextMediaReceiver (@NonNull Text text, @Nullabl performWithViews(view -> view.invalidateReplyTextMediaReceiver(this, text, textMedia)); } + public final void invalidateReplyMarkupTextMedia () { + final boolean force = inlineKeyboard != null && inlineKeyboard.needTextMedia(); + performWithViews(view -> { + ComplexReceiver receiver = view.getReplyMarkupTextMediaReceiver(force); + if (receiver != null) { + requestReplyMarkupTextMedia(receiver); + } + }); + } + + public final void invalidateReplyMarkupTextMedia (@NonNull Text text, @Nullable TextMedia textMedia) { + performWithViews(view -> view.invalidateReplyMarkupTextMediaReceiver(this, text, textMedia)); + } + // Touch public boolean allowLongPress (float x, float y) { @@ -4290,6 +4304,23 @@ public final void requestAllTextMedia (MessageView view) { receiver.clear(); } } + + if (inlineKeyboard != null && inlineKeyboard.needTextMedia()) { + inlineKeyboard.requestTextMedia(view.getReplyMarkupTextMediaReceiver(true)); + } else { + ComplexReceiver receiver = view.getReplyMarkupTextMediaReceiver(false); + if (receiver != null) { + receiver.clear(); + } + } + } + + public final void requestReplyMarkupTextMedia (ComplexReceiver textMediaReceiver) { + if (inlineKeyboard != null) { + inlineKeyboard.requestTextMedia(textMediaReceiver); + } else { + textMediaReceiver.clear(); + } } public final void requestAuthorTextMedia (ComplexReceiver textMediaReceiver) { @@ -6269,6 +6300,8 @@ public final void onDestroy () { forwardInfo.destroy(); if (replyData != null) replyData.performDestroy(); + if (inlineKeyboard != null) + inlineKeyboard.performDestroy(); messageReactions.performDestroy(); setViewAttached(false); onMessageContainerDestroyed(); From 6707bd92143597747795c3efecccbe199394ab23 Mon Sep 17 00:00:00 2001 From: redgreen Date: Mon, 3 Aug 2026 09:09:12 +0300 Subject: [PATCH 2/5] Display custom emoji icons and styles on bot keyboard buttons Same Bot API 9.4 fields for reply keyboards: CommandKeyboardLayout buttons become CustomEmojiTextView with a CustomEmojiId span for iconCustomEmojiId, and ButtonStyle drives the per-button text color. Co-Authored-By: Claude Fable 5 --- .../component/chat/CommandKeyboardLayout.java | 80 ++++++++++++++++--- .../challegram/ui/MessagesController.java | 5 +- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java b/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java index dcd0768e80..dc0583f936 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java @@ -15,6 +15,8 @@ package org.thunderdog.challegram.component.chat; import android.content.Context; +import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.util.TypedValue; import android.view.Gravity; import android.view.View; @@ -26,19 +28,24 @@ import androidx.annotation.Nullable; import org.drinkless.tdlib.TdApi; +import org.thunderdog.challegram.emoji.CustomEmojiId; import org.thunderdog.challegram.navigation.ViewController; +import org.thunderdog.challegram.telegram.Tdlib; import org.thunderdog.challegram.theme.ColorId; import org.thunderdog.challegram.theme.Theme; import org.thunderdog.challegram.tool.Keyboard; import org.thunderdog.challegram.tool.Screen; import org.thunderdog.challegram.tool.Views; +import org.thunderdog.challegram.util.EmojiStatusHelper; import org.thunderdog.challegram.v.EditText; -import org.thunderdog.challegram.widget.EmojiTextView; +import org.thunderdog.challegram.widget.CustomEmojiTextView; import org.thunderdog.challegram.widget.TextView; import me.vkryl.android.ViewUtils; +import me.vkryl.core.lambda.Destroyable; +import tgx.td.Td; -public class CommandKeyboardLayout extends ViewGroup implements ViewTreeObserver.OnPreDrawListener, View.OnClickListener { +public class CommandKeyboardLayout extends ViewGroup implements ViewTreeObserver.OnPreDrawListener, View.OnClickListener, Destroyable { private boolean oneTime; private int rowsCount; private int[] columnCount; @@ -51,8 +58,11 @@ public class CommandKeyboardLayout extends ViewGroup implements ViewTreeObserver private Callback callback; - public CommandKeyboardLayout (Context context) { + private final @Nullable Tdlib tdlib; + + public CommandKeyboardLayout (Context context, @Nullable Tdlib tdlib) { super(context); + this.tdlib = tdlib; spacingBig = Screen.dp(15f); spacing = Screen.dp(10f); minSize = Screen.dp(42f); @@ -103,7 +113,8 @@ private void fillLayout (TdApi.KeyboardButton[][] rows) { text.setVisibility(View.VISIBLE); } text.setTag(c); - text.setText(c.text != null ? c.text : ""); + text.setText(buildButtonText(c)); + applyButtonStyle(text, c.style); j++; } @@ -118,6 +129,9 @@ private void fillLayout (TdApi.KeyboardButton[][] rows) { if (themeProvider != null) { themeProvider.removeThemeListenerByTarget(view); } + if (view instanceof Destroyable) { + ((Destroyable) view).performDestroy(); + } removeViewAt(i); } else { view.setVisibility(View.GONE); @@ -135,17 +149,13 @@ private void resizeKeyboard (boolean customSize) { private @Nullable ViewController themeProvider; private TextView genButton () { - TextView text = new EmojiTextView(getContext()); + TextView text = new CustomEmojiTextView(getContext(), tdlib); text.setScrollDisabled(true); ViewUtils.setBackground(text, Theme.rectSelector(4f, 0f, ColorId.chatKeyboardButton)); if (themeProvider != null) { themeProvider.addThemeInvalidateListener(text); } text.setGravity(Gravity.CENTER); - text.setTextColor(Theme.textAccentColor()); - if (themeProvider != null) { - themeProvider.addThemeTextAccentColorListener(text); - } text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f); text.setOnClickListener(this); //noinspection ResourceType @@ -154,6 +164,58 @@ private TextView genButton () { return text; } + private CharSequence buildButtonText (TdApi.KeyboardButton button) { + String text = button.text != null ? button.text : ""; + if (button.iconCustomEmojiId != 0 && tdlib != null) { + SpannableStringBuilder b = new SpannableStringBuilder(); + b.append(EmojiStatusHelper.EMOJI); + b.setSpan(new CustomEmojiId(button.iconCustomEmojiId, false), 0, b.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + if (!text.isEmpty()) { + b.append(' ').append(text); + } + return b; + } + return text; + } + + private void applyButtonStyle (TextView text, @Nullable TdApi.ButtonStyle style) { + final @ColorId int colorId = resolveStyleColorId(style); + text.setTextColor(Theme.getColor(colorId)); + if (themeProvider != null) { + themeProvider.addOrUpdateThemeTextColorListener(text, colorId); + } + } + + private static @ColorId int resolveStyleColorId (@Nullable TdApi.ButtonStyle style) { + if (style == null) { + return ColorId.text; + } + switch (style.getConstructor()) { + case TdApi.ButtonStyleDefault.CONSTRUCTOR: + return ColorId.text; + case TdApi.ButtonStylePrimary.CONSTRUCTOR: + return ColorId.textNeutral; + case TdApi.ButtonStyleSuccess.CONSTRUCTOR: + return ColorId.iconPositive; + case TdApi.ButtonStyleDanger.CONSTRUCTOR: + return ColorId.textNegative; + default: { + Td.assertButtonStyle_da99259d(); + throw Td.unsupported(style); + } + } + } + + @Override + public void performDestroy () { + for (int i = getChildCount() - 1; i >= 0; i--) { + View view = getChildAt(i); + if (view instanceof Destroyable) { + ((Destroyable) view).performDestroy(); + } + } + } + @Override public void onClick (View v) { if (callback == null) { diff --git a/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java b/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java index 2bf09a1383..e22c8d0f97 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java @@ -4408,6 +4408,9 @@ public void destroy () { if (emojiLayout != null) { emojiLayout.destroy(); } + if (keyboardLayout != null) { + keyboardLayout.performDestroy(); + } manager.release(); } @@ -7561,7 +7564,7 @@ private void openCommandsKeyboard (long messageId, TdApi.ReplyMarkupShowKeyboard keyboardWrapper = new ScrollView(context()); ViewSupport.setThemedBackground(keyboardWrapper, ColorId.chatKeyboard, this); - keyboardLayout = new CommandKeyboardLayout(context()); + keyboardLayout = new CommandKeyboardLayout(context(), tdlib); keyboardLayout.setThemeProvider(this); keyboardLayout.setCallback(this); From 855abd707ec7fba1ce90c808ae99f317280f923d Mon Sep 17 00:00:00 2001 From: redgreen Date: Fri, 7 Aug 2026 21:03:22 +0300 Subject: [PATCH 3/5] Fix mid-word wrapping on bot keyboard buttons with icons Icon width made tight labels wrap at arbitrary characters. Force greedy break strategy without hyphenation and cap at 2 lines with end ellipsis, matching inline keyboard behavior. Co-Authored-By: Claude Fable 5 --- .../challegram/component/chat/CommandKeyboardLayout.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java b/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java index dc0583f936..1b0b7224bb 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java @@ -15,8 +15,11 @@ package org.thunderdog.challegram.component.chat; import android.content.Context; +import android.os.Build; +import android.text.Layout; import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; import android.view.View; @@ -157,6 +160,12 @@ private TextView genButton () { } text.setGravity(Gravity.CENTER); text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f); + text.setMaxLines(2); + text.setEllipsize(TextUtils.TruncateAt.END); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + text.setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE); + text.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE); + } text.setOnClickListener(this); //noinspection ResourceType text.setLayoutParams(new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); From c2dec6389f436789505b62b388af0c27f7f24b2a Mon Sep 17 00:00:00 2001 From: redgreen Date: Sat, 8 Aug 2026 10:21:04 +0300 Subject: [PATCH 4/5] Fit inline button font so the icon no longer causes mid-word breaks The icon footprint narrows the EmojiString layout, so labels that previously fit exactly were char-broken by StaticLayout. Shrink the font (14..10dp) until the widest word fits the reduced width; measure the default step at 15dp matching the actual getBoldPaint14 size. Co-Authored-By: Claude Fable 5 --- .../challegram/data/TGInlineKeyboard.java | 67 +++++++++++++++++-- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java b/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java index 24b4b456d6..5895189d94 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGInlineKeyboard.java @@ -48,6 +48,7 @@ import org.thunderdog.challegram.theme.ColorId; import org.thunderdog.challegram.theme.Theme; import org.thunderdog.challegram.tool.Drawables; +import org.thunderdog.challegram.tool.Fonts; import org.thunderdog.challegram.tool.Paints; import org.thunderdog.challegram.tool.Screen; import org.thunderdog.challegram.tool.UI; @@ -79,6 +80,7 @@ public class TGInlineKeyboard { private static final float CURRENCY_TEXT_SIZE_DP = 10f; private static final float BUTTON_TEXT_SIZE_DP = 14f; + private static final float MIN_BUTTON_TEXT_SIZE_DP = 10f; private final @NonNull TGMessage context; private final @NonNull TGMessage parent; @@ -472,6 +474,7 @@ public static class Button implements FactorAnimator.Target, DrawableProvider { private @Nullable Text iconText; private @ColorId int styleColorId; private int iconTextColor; + private float textSizeDp = BUTTON_TEXT_SIZE_DP; public Button (TGInlineKeyboard context, @NonNull TGMessage parent, TdApi.InlineKeyboardButton button, int maxWidth) { this.context = context; @@ -480,13 +483,14 @@ public Button (TGInlineKeyboard context, @NonNull TGMessage parent, TdApi.Inline this.dirtyRect = new Rect(); String text = uppercase(cleanButtonText(button.text)); this.needFakeBold = Text.needFakeBold(text); - TextPaint textPaint = Paints.getBoldPaint14(needFakeBold); this.iconCustomEmojiId = button.iconCustomEmojiId; if (button.iconCustomEmojiId != 0) { this.iconText = buildIconText(button.iconCustomEmojiId); } this.styleColorId = resolveStyleColorId(button.style); - this.wrapper = new EmojiString(text, Math.max(0, maxWidth - getIconFootprint()), textPaint); + int textMaxWidth = Math.max(0, maxWidth - getIconFootprint()); + this.textSizeDp = fitTextSizeDp(text, textMaxWidth); + this.wrapper = new EmojiString(text, textMaxWidth, textPaintFor(textSizeDp)); this.type = button.type; if (type.getConstructor() == TdApi.InlineKeyboardButtonTypeBuy.CONSTRUCTOR) { currencyChar = CurrencyUtils.getCurrencyChar(((TdApi.MessageInvoice) parent.getMessage().content).currency); @@ -562,6 +566,54 @@ private Text buildIconText (long customEmojiId) { return customColorId != ColorId.NONE ? customColorId : styleColorId; } + // The icon narrows the text layout, so a label that used to fit exactly may + // start breaking mid-word (StaticLayout splits words wider than the layout). + // Shrink the font until the widest word fits instead. + private float fitTextSizeDp (String text, int availWidth) { + if (availWidth <= 0 || iconText == null) { + return BUTTON_TEXT_SIZE_DP; + } + TextPaint paint = newTextPaint(BUTTON_TEXT_SIZE_DP); + for (float size = BUTTON_TEXT_SIZE_DP; size >= MIN_BUTTON_TEXT_SIZE_DP; size -= .5f) { + // Default size must be measured at the actual render size of + // Paints.getBoldPaint14, which is 15dp in both of its branches + paint.setTextSize(size == BUTTON_TEXT_SIZE_DP ? Screen.dp(15f) : Screen.dpf(size)); + if (maxWordWidth(text, paint) <= availWidth) { + return size; + } + } + return MIN_BUTTON_TEXT_SIZE_DP; + } + + private TextPaint newTextPaint (float sizeDp) { + TextPaint paint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + paint.setTypeface(needFakeBold ? Fonts.getRobotoRegular() : Fonts.getRobotoMedium()); + paint.setFakeBoldText(needFakeBold); + paint.setTextSize(Screen.dpf(sizeDp)); + return paint; + } + + private TextPaint textPaintFor (float sizeDp) { + // Shared cached paint for the default size; a dedicated instance otherwise, + // as EmojiString retains the paint and shared ones are mutated elsewhere + return sizeDp == BUTTON_TEXT_SIZE_DP ? Paints.getBoldPaint14(needFakeBold) : newTextPaint(sizeDp); + } + + private static float maxWordWidth (String text, TextPaint paint) { + float max = 0f; + int length = text.length(); + int start = 0; + for (int i = 0; i <= length; i++) { + if (i == length || text.charAt(i) == ' ' || text.charAt(i) == '\n') { + if (i > start) { + max = Math.max(max, paint.measureText(text, start, i)); + } + start = i + 1; + } + } + return max; + } + public Button (TGInlineKeyboard context, @NonNull TGMessage parent, String text, @DrawableRes int iconRes, int maxWidth) { this.context = context; this.parent = parent; @@ -594,10 +646,11 @@ public void set (TdApi.InlineKeyboardButton button, int maxWidth) { String text = uppercase(cleanButtonText(button.text)); final boolean reset = !wrapper.getText().equals(text); final int textMaxWidth = Math.max(0, maxWidth - getIconFootprint()); - if (reset || wrapper.getMaxWidth() != textMaxWidth) { - this.needFakeBold = Text.needFakeBold(text); - TextPaint textPaint = Paints.getBoldPaint14(needFakeBold); - this.wrapper = new EmojiString(uppercase(text), textMaxWidth, textPaint); + this.needFakeBold = Text.needFakeBold(text); + final float newTextSizeDp = fitTextSizeDp(text, textMaxWidth); + if (reset || wrapper.getMaxWidth() != textMaxWidth || this.textSizeDp != newTextSizeDp) { + this.textSizeDp = newTextSizeDp; + this.wrapper = new EmojiString(uppercase(text), textMaxWidth, textPaintFor(newTextSizeDp)); } if (reset || !Td.equalsTo(type, button.type)) { if (contextId == Integer.MAX_VALUE) { @@ -749,7 +802,7 @@ public void draw (MessageView view, Canvas c, int cx, int cy, int buttonWidth, i iconText.draw(c, iconX, cy + (buttonHeight - iconHeight) / 2, null, 1f, view.getReplyMarkupTextMediaReceiver(true)); } Paints.getBoldPaint14(needFakeBold, Theme.inlineTextColor(isOutBubble)); - wrapper.draw(c, textX, cy + Screen.dp(12f), textColor, true); + wrapper.draw(c, textX, cy + Screen.dp(12f + (BUTTON_TEXT_SIZE_DP - textSizeDp) / 2f), textColor, true); if (type != null) { int iconColor = buttonColorId != ColorId.NONE ? Theme.getColor(buttonColorId) : Theme.inlineIconColor(isOutBubble); From 79c012fa3942cdfe066f444968a74afa42078b7c Mon Sep 17 00:00:00 2001 From: redgreen Date: Sat, 8 Aug 2026 13:35:55 +0300 Subject: [PATCH 5/5] Word-fit font scaling for bot keyboard buttons Auto-size cannot prevent mid-word breaks (a split-word layout still satisfies its constraints); shrink the font until the widest word and a 2-line label fit instead, refitting when keyboards are swapped. Co-Authored-By: Claude Fable 5 --- .../component/chat/CommandKeyboardLayout.java | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java b/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java index 1b0b7224bb..042c14c83a 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java @@ -19,6 +19,8 @@ import android.text.Layout; import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.text.StaticLayout; +import android.text.TextPaint; import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; @@ -88,6 +90,7 @@ public int getSize () { public void setKeyboard (TdApi.ReplyMarkupShowKeyboard keyboard) { oneTime = keyboard.oneTime; fillLayout(keyboard.rows); + fitPending = true; resizeKeyboard(keyboard.resizeKeyboard); layoutChildren(Screen.currentWidth(), false, 0); requestLayout(); @@ -151,6 +154,54 @@ private void resizeKeyboard (boolean customSize) { private @Nullable ViewController themeProvider; + private static final float MAX_BUTTON_TEXT_SIZE_DP = 16f; + private static final float MIN_BUTTON_TEXT_SIZE_DP = 11f; + + private boolean fitPending; + + // Auto-size can leave mid-word breaks: a two-line layout with a split word still + // "fits" its constraints. Instead shrink the font until the widest word (with the + // emoji icon span measured through the paint) fits the button, so greedy breaking + // never has to split inside a word. + private void fitButtonText (TextView text, int availWidth) { + CharSequence label = text.getText(); + if (label == null || label.length() == 0 || availWidth <= 0) { + return; + } + TextPaint paint = new TextPaint(text.getPaint()); + float fitSize = MIN_BUTTON_TEXT_SIZE_DP; + for (float size = MAX_BUTTON_TEXT_SIZE_DP; size >= MIN_BUTTON_TEXT_SIZE_DP; size -= .5f) { + paint.setTextSize(Screen.dpf(size)); + if (maxWordWidth(label, paint) <= availWidth && lineCount(label, paint, availWidth) <= 2) { + fitSize = size; + break; + } + } + if (text.getTextSize() != Screen.dpf(fitSize)) { + text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, fitSize); + } + } + + @SuppressWarnings("deprecation") + private static int lineCount (CharSequence label, TextPaint paint, int availWidth) { + return new StaticLayout(label, paint, Math.max(availWidth, 1), Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false).getLineCount(); + } + + private static float maxWordWidth (CharSequence label, TextPaint paint) { + float max = 0f; + int length = label.length(); + int start = 0; + for (int i = 0; i <= length; i++) { + if (i == length || label.charAt(i) == ' ') { + if (i > start) { + max = Math.max(max, Layout.getDesiredWidth(label, start, i, paint)); + } + start = i + 1; + } + } + return max; + } + private TextView genButton () { TextView text = new CustomEmojiTextView(getContext(), tdlib); text.setScrollDisabled(true); @@ -159,13 +210,13 @@ private TextView genButton () { themeProvider.addThemeInvalidateListener(text); } text.setGravity(Gravity.CENTER); - text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f); text.setMaxLines(2); text.setEllipsize(TextUtils.TruncateAt.END); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { text.setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE); text.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE); } + text.setTextSize(TypedValue.COMPLEX_UNIT_DIP, MAX_BUTTON_TEXT_SIZE_DP); text.setOnClickListener(this); //noinspection ResourceType text.setLayoutParams(new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)); @@ -311,7 +362,8 @@ protected void onLayout (boolean changed, int l, int t, int r, int b) { wasChanged = 0; } - if (changed) { + if (changed || fitPending) { + fitPending = false; layoutChildren(width, true, t); } else { for (int i = 0; i < getChildCount(); i++) { @@ -346,6 +398,9 @@ private void layoutChildren (int currentWidth, boolean layout, int top) { View v = getChildAt(i); layoutChild(v, cx, cy, cw, ch); if (layout) { + if (v instanceof TextView) { + fitButtonText((TextView) v, cw - Screen.dp(8f)); + } v.measure(MeasureSpec.makeMeasureSpec(cw, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(ch, MeasureSpec.EXACTLY)); v.layout(cx, top + cy, cw + cx, top + cy + ch); }