Skip to content

Commit f63c36c

Browse files
authored
[mqtt.awtrix3] Parse font tags for multi-colored app texts (#19466)
* Allow color tags in app text Signed-off-by: Thomas Lauterbach <2452988+DrRSatzteil@users.noreply.github.com>
1 parent e8d136e commit f63c36c

3 files changed

Lines changed: 316 additions & 3 deletions

File tree

  • bundles/org.openhab.binding.mqtt.awtrixlight

bundles/org.openhab.binding.mqtt.awtrixlight/README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,13 +110,45 @@ The button events can be used by rules to change the displayed app or perform an
110110
| `rainbow` | Switch | RW | Enable rainbow effect: Uses a rainbow effect for the displayed text. |
111111
| `reset`* | Switch | RW | Reset app to default state: All channels will be reset to their default values. |
112112
| `scroll-speed` | Number:Dimensionless | RW | Text scrolling speed: Provide as percentage value. The original speed is 100%. Values above 100% will increase the scrolling speed, values below 100% will decrease it. Setting this value to 0 will disable scrolling completely. |
113-
| `text` | String | RW | Text to display. |
113+
| `text` | String | RW | Text to display. Supports inline color formatting with font color tags (see Text Color Tags section below for details). |
114114
| `text-case` | Number:Dimensionless | RW | Set text case (0=normal, 1=uppercase, 2=lowercase). |
115115
| `text-offset` | Number:Dimensionless | RW | Text offset position: Horizontal offset of the text in pixels. |
116116
| `top-text` | String | RW | Draws the text on the top of the display. |
117117

118118
\* Cannot be used with notification Actions (see section Actions)
119119

120+
## Text Color Tags
121+
122+
The `text` channel supports inline color formatting using simple HTML-like font color tags. This allows you to display text with multiple colors in a single app.
123+
124+
### Syntax
125+
126+
Use the following format to apply colors to specific parts of your text:
127+
128+
```html
129+
<font color="#RRGGBB">colored text</font>
130+
```
131+
132+
Where `RRGGBB` is a 6-digit hexadecimal color code (e.g., `FF0000` for red, `00FF00` for green, `0000FF` for blue).
133+
134+
### Examples
135+
136+
```java
137+
// Multiple colored segments - "Hello" and "in" will use the color from the color channel, "World" and "Color" will use the specified colors
138+
Custom_Text.sendCommand('Hello <font color="#FF0000">World</font> in <font color="#00FF00">Color</font>')
139+
140+
// All text in custom color
141+
Custom_Text.sendCommand('<font color="#FF6600">Temperature: 25°C</font>')
142+
```
143+
144+
### Important Notes
145+
146+
- **Default color**: Text outside of `<font>` tags will be displayed in the color defined by the `color` channel.
147+
- **Text effects disabled**: When color tags are used, the `blink`, `fade`, and `rainbow` effects are automatically disabled, as each text segment has its own color. The `gradient-color` channel is also ignored.
148+
- **Tags cannot be nested**: `<font>` tags must not be placed inside other `<font>` tags. Nesting is not supported and will result in incorrect parsing.
149+
- **Case-insensitive hex values**: Both uppercase and lowercase hex values are supported (e.g., `#FF0000` or `#ff0000`).
150+
- **Malformed tags**: If a tag is malformed (e.g., missing closing tag), the parser will gracefully handle it by applying the default color.
151+
120152
## Actions
121153

122154
The binding supports various actions that can be used in rules to control the Awtrix display. To use these actions, you need to import them in your rules (see examples below).

bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixApp.java

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,38 @@
1212
*/
1313
package org.openhab.binding.mqtt.awtrixlight.internal.app;
1414

15+
import java.util.ArrayList;
1516
import java.util.Arrays;
1617
import java.util.HashMap;
18+
import java.util.List;
1719
import java.util.Map;
1820
import java.util.stream.Collectors;
1921

2022
import org.eclipse.jdt.annotation.NonNullByDefault;
2123
import org.eclipse.jdt.annotation.Nullable;
2224
import org.openhab.binding.mqtt.awtrixlight.internal.Helper;
2325

26+
import com.google.gson.annotations.SerializedName;
27+
28+
/**
29+
* The {@link TextSegment} is the representation of a text segment in an App.
30+
*
31+
* @author Thomas Lauterbach - Initial contribution
32+
*/
33+
@NonNullByDefault
34+
class TextSegment {
35+
@SerializedName("t")
36+
public final String text;
37+
38+
@SerializedName("c")
39+
public final String color;
40+
41+
public TextSegment(String text, String color) {
42+
this.text = text;
43+
this.color = color;
44+
}
45+
}
46+
2447
/**
2548
* The {@link AwtrixApp} is the representation of the current app configuration and provides a method to create a config
2649
* string for the clock.
@@ -30,6 +53,8 @@
3053
@NonNullByDefault
3154
public class AwtrixApp {
3255

56+
private static final int CLOSING_TAG_LENGTH = 7; // 7 = "</font>".length()
57+
3358
public static final String DEFAULT_TEXT = "New Awtrix App";
3459
public static final int DEFAULT_TEXTCASE = 0;
3560
public static final boolean DEFAULT_TOPTEXT = false;
@@ -88,6 +113,9 @@ public class AwtrixApp {
88113
// effectSettings properties
89114
private Map<String, Object> effectSettings;
90115

116+
private static final java.util.regex.Pattern TEXT_COLOR_PATTERN = java.util.regex.Pattern
117+
.compile("color=\"#([0-9A-Fa-f]{6})\"");
118+
91119
public AwtrixApp() {
92120
this.effectSettings = new HashMap<String, Object>();
93121
this.effectSettings.put("speed", DEFAULT_EFFECTSPEED);
@@ -362,7 +390,12 @@ public String toString() {
362390

363391
public Map<String, Object> getAppParams() {
364392
Map<String, Object> fields = new HashMap<String, Object>();
365-
fields.put("text", this.text);
393+
if (textHasColorTags(this.text)) {
394+
fields.put("text", this.parseTextSegments());
395+
} else {
396+
fields.put("text", this.text);
397+
fields.putAll(getTextEffectConfig());
398+
}
366399
fields.put("textCase", this.textCase);
367400
fields.put("topText", this.topText);
368401
fields.put("textOffset", this.textOffset);
@@ -371,7 +404,6 @@ public Map<String, Object> getAppParams() {
371404
fields.put("lifetimeMode", this.lifetimeMode);
372405
fields.put("overlay", this.overlay);
373406
fields.putAll(getColorConfig());
374-
fields.putAll(getTextEffectConfig());
375407
fields.putAll(getBackgroundConfig());
376408
fields.putAll(getIconConfig());
377409
fields.put("duration", this.duration);
@@ -515,6 +547,92 @@ private Map<String, Object> getColorConfig() {
515547
return fields;
516548
}
517549

550+
private boolean textHasColorTags(String text) {
551+
if (text.isEmpty()) {
552+
return false;
553+
}
554+
// Check for the basic structure and use regex to validate color format
555+
// We need both opening and closing tags, and at least one valid color attribute
556+
return text.contains("<font") && text.contains("</font>") && TEXT_COLOR_PATTERN.matcher(text).find();
557+
}
558+
559+
private static String rgbToHex(int[] rgb) {
560+
// Ensure values are in 0-255 range
561+
int r = Math.min(255, Math.max(0, rgb[0]));
562+
int g = Math.min(255, Math.max(0, rgb[1]));
563+
int b = Math.min(255, Math.max(0, rgb[2]));
564+
565+
// Format as 6-digit hex string, padding with zeros if needed
566+
return String.format("%02x%02x%02x", r, g, b);
567+
}
568+
569+
private List<TextSegment> parseTextSegments() {
570+
List<TextSegment> segments = new ArrayList<>();
571+
if (this.text.isEmpty()) {
572+
return segments;
573+
}
574+
575+
String remaining = this.text;
576+
String defaultColor = rgbToHex(this.color);
577+
578+
while (true) {
579+
int startTag = remaining.indexOf("<font");
580+
if (startTag < 0) {
581+
// No more tags, add remaining text
582+
if (!remaining.isEmpty()) {
583+
segments.add(new TextSegment(remaining, defaultColor));
584+
}
585+
break;
586+
}
587+
588+
// Add text before the tag
589+
if (startTag > 0) {
590+
segments.add(new TextSegment(remaining.substring(0, startTag), defaultColor));
591+
}
592+
593+
// Find the end of the opening tag
594+
int endTag = remaining.indexOf(">", startTag);
595+
if (endTag < 0) {
596+
// Malformed tag, add everything and stop
597+
segments.add(new TextSegment(remaining, defaultColor));
598+
break;
599+
}
600+
601+
// Extract color from tag (or use default)
602+
String tag = remaining.substring(startTag, endTag + 1);
603+
String color = extractColor(tag);
604+
if (color == null) {
605+
color = defaultColor;
606+
}
607+
608+
// Find the closing tag
609+
int closeTag = remaining.indexOf("</font>", endTag);
610+
if (closeTag < 0) {
611+
// No closing tag, add rest with color and stop
612+
segments.add(new TextSegment(remaining.substring(endTag + 1), color));
613+
break;
614+
}
615+
616+
// Add text between tags
617+
segments.add(new TextSegment(remaining.substring(endTag + 1, closeTag), color));
618+
619+
// Move past the closing tag
620+
remaining = remaining.substring(closeTag + CLOSING_TAG_LENGTH);
621+
}
622+
623+
return segments;
624+
}
625+
626+
@Nullable
627+
private String extractColor(String tag) {
628+
java.util.regex.Matcher matcher = TEXT_COLOR_PATTERN.matcher(tag);
629+
if (matcher.find()) {
630+
// Group 1 contains the hex color value (without the #)
631+
return matcher.group(1).toLowerCase();
632+
}
633+
return null;
634+
}
635+
518636
private Map<String, Object> getTextEffectConfig() {
519637
Map<String, Object> fields = new HashMap<String, Object>();
520638
if (Arrays.equals(this.color, DEFAULT_COLOR) && Arrays.equals(this.gradient, DEFAULT_GRADIENT)) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
* Copyright (c) 2010-2025 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.binding.mqtt.awtrixlight.internal.app;
14+
15+
import static org.junit.jupiter.api.Assertions.*;
16+
17+
import org.junit.jupiter.api.Test;
18+
19+
import com.google.gson.JsonArray;
20+
import com.google.gson.JsonObject;
21+
import com.google.gson.JsonParser;
22+
23+
/**
24+
* Test cases for the {@link AwtrixApp} object.
25+
*
26+
* @author Thomas Lauterbach - Initial contribution
27+
*/
28+
class AwtrixAppTest {
29+
30+
@Test
31+
void testTextWithMixedContent() {
32+
AwtrixApp app = new AwtrixApp();
33+
app.setText("This is a <font color=\"#cc33cc\">Multi</font> Colored <font color=\"#34ab12\">Text</font>!");
34+
app.setColor(new int[] { 255, 255, 255 }); // Default white color
35+
36+
String config = app.getAppConfig();
37+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
38+
JsonArray text = json.get("text").getAsJsonArray();
39+
40+
// Should be 5 segments: "This is a ", "Multi", " Colored ", "Text", "!"
41+
assertEquals(5, text.size());
42+
43+
// Verify each segment's text
44+
assertEquals("This is a ", text.get(0).getAsJsonObject().get("t").getAsString());
45+
assertEquals("Multi", text.get(1).getAsJsonObject().get("t").getAsString());
46+
assertEquals(" Colored ", text.get(2).getAsJsonObject().get("t").getAsString());
47+
assertEquals("Text", text.get(3).getAsJsonObject().get("t").getAsString());
48+
assertEquals("!", text.get(4).getAsJsonObject().get("t").getAsString());
49+
50+
// Optionally, verify colors
51+
assertEquals("ffffff", text.get(0).getAsJsonObject().get("c").getAsString()); // Default color
52+
assertEquals("cc33cc", text.get(1).getAsJsonObject().get("c").getAsString());
53+
assertEquals("ffffff", text.get(2).getAsJsonObject().get("c").getAsString()); // Default color
54+
assertEquals("34ab12", text.get(3).getAsJsonObject().get("c").getAsString());
55+
assertEquals("ffffff", text.get(4).getAsJsonObject().get("c").getAsString()); // Default color
56+
}
57+
58+
@Test
59+
void testTextStartingWithColor() {
60+
AwtrixApp app = new AwtrixApp();
61+
app.setText("<font color=\"#ff0000\">Red</font> text at start");
62+
app.setColor(new int[] { 0, 0, 0 }); // Default black color
63+
64+
String config = app.getAppConfig();
65+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
66+
67+
assertTrue(json.has("text"));
68+
JsonArray text = json.get("text").getAsJsonArray();
69+
assertEquals(2, text.size());
70+
assertEquals("Red", text.get(0).getAsJsonObject().get("t").getAsString());
71+
assertEquals(" text at start", text.get(1).getAsJsonObject().get("t").getAsString());
72+
}
73+
74+
@Test
75+
void testTextWithBrackets() {
76+
AwtrixApp app = new AwtrixApp();
77+
app.setText("<font color=\"#ff0000\">Red <- Nice!</font>");
78+
app.setColor(new int[] { 0, 0, 0 }); // Default black color
79+
80+
String config = app.getAppConfig();
81+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
82+
83+
assertTrue(json.has("text"));
84+
JsonArray text = json.get("text").getAsJsonArray();
85+
assertEquals(1, text.size());
86+
assertEquals("Red <- Nice!", text.get(0).getAsJsonObject().get("t").getAsString());
87+
}
88+
89+
@Test
90+
void testAdjacentColorSegments() {
91+
AwtrixApp app = new AwtrixApp();
92+
app.setText("<font color=\"#ff0000\">Red</font><font color=\"#0000ff\">Blue</font>");
93+
app.setColor(new int[] { 0, 0, 0 });
94+
95+
String config = app.getAppConfig();
96+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
97+
98+
assertTrue(json.has("text"));
99+
JsonArray text = json.get("text").getAsJsonArray();
100+
assertEquals(2, text.size());
101+
assertEquals("Red", text.get(0).getAsJsonObject().get("t").getAsString());
102+
assertEquals("Blue", text.get(1).getAsJsonObject().get("t").getAsString());
103+
}
104+
105+
@Test
106+
void testAdjacentColorSegmentsWithSpace() {
107+
AwtrixApp app = new AwtrixApp();
108+
app.setText("<font color=\"#ff0000\">Red</font> <font color=\"#0000ff\">Blue</font>");
109+
app.setColor(new int[] { 0, 0, 0 });
110+
111+
String config = app.getAppConfig();
112+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
113+
114+
assertTrue(json.has("text"));
115+
JsonArray text = json.get("text").getAsJsonArray();
116+
assertEquals(3, text.size());
117+
assertEquals("Red", text.get(0).getAsJsonObject().get("t").getAsString());
118+
assertEquals(" ", text.get(1).getAsJsonObject().get("t").getAsString());
119+
assertEquals("Blue", text.get(2).getAsJsonObject().get("t").getAsString());
120+
}
121+
122+
@Test
123+
void testPlainText() {
124+
AwtrixApp app = new AwtrixApp();
125+
app.setText("Just plain text with no colors");
126+
app.setColor(new int[] { 18, 52, 86 }); // Some default color
127+
128+
String config = app.getAppConfig();
129+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
130+
131+
assertTrue(json.has("text"));
132+
String text = json.get("text").getAsString();
133+
assertEquals("Just plain text with no colors", text);
134+
}
135+
136+
@Test
137+
void testEmptyString() {
138+
AwtrixApp app = new AwtrixApp();
139+
app.setText("");
140+
app.setColor(new int[] { 0, 0, 0 });
141+
142+
String config = app.getAppConfig();
143+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
144+
145+
// Depending on implementation, the text field might be empty or not present
146+
if (json.has("text")) {
147+
assertTrue(json.get("text").getAsString().isEmpty());
148+
}
149+
}
150+
151+
@Test
152+
void testOnlySpaces() {
153+
AwtrixApp app = new AwtrixApp();
154+
app.setText(" ");
155+
app.setColor(new int[] { 0, 0, 0 });
156+
157+
String config = app.getAppConfig();
158+
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
159+
160+
assertTrue(json.has("text"));
161+
assertEquals(" ", json.get("text").getAsString());
162+
}
163+
}

0 commit comments

Comments
 (0)