Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit c59a948

Browse files
staticoclaude
andcommitted
Fix Unicode/emoji character width calculations
- Add string-width utility using npm string-width package - Use Intl.Segmenter for proper grapheme iteration - Replace custom width functions in NodesPanel - Update all components to use fitVisual() for name display - Add terminal compatibility note to README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 53fa5e5 commit c59a948

8 files changed

Lines changed: 51 additions & 52 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ Options:
201201
| u | Toggle uplink |
202202
| D | Toggle downlink |
203203

204+
## Terminal Compatibility
205+
206+
For proper emoji and Unicode character display, ensure your terminal uses Unicode-compliant width calculations:
207+
208+
- **Ghostty** - Uses Unicode widths by default (`grapheme-width-method = unicode`)
209+
- **Kitty** - Uses Unicode widths by default
210+
- **iTerm2** - Enable in Preferences → Profiles → Text → "Unicode version 9+ widths"
211+
- **Terminal.app** - May have issues with some emoji
212+
204213
## License
205214

206215
MIT

src/ui/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2461,7 +2461,7 @@ export function App({ address, packetStore, nodeStore, skipConfig = false, skipN
24612461
const statusColor = status === "connected" ? theme.status.online : theme.status.offline;
24622462
const nodeCount = nodes.length;
24632463

2464-
const helpHint = `v${packageJson.version} [?] Help`;
2464+
const helpHint = `v${packageJson.version} | [?] Help`;
24652465

24662466
// Show connecting screen
24672467
if (!transport) {

src/ui/components/ChatPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { theme } from "../theme";
55
import type { DbMessage } from "../../db";
66
import type { NodeStore } from "../../protocol/node-store";
77
import type { ChannelInfo } from "../App";
8+
import { fitVisual } from "../../utils/string-width";
89

910
const MESSAGE_TIMEOUT_MS = 30000;
1011

@@ -360,7 +361,7 @@ function MessageRow({ message, nodeStore, isOwn, isSelected, width }: MessageRow
360361
{lineIndex === 0 ? (
361362
<Text>
362363
<Text color={theme.fg.muted}>[{time}] </Text>
363-
<Text color={nameColor}>{fromName.padEnd(10)}</Text>
364+
<Text color={nameColor}>{fitVisual(fromName, 10)}</Text>
364365
<Text> </Text>
365366
</Text>
366367
) : (

src/ui/components/DMPanel.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { theme } from "../theme";
55
import type { DbMessage, DMConversation } from "../../db";
66
import type { NodeStore } from "../../protocol/node-store";
77
import { formatNodeId } from "../../utils/hex";
8+
import { fitVisual } from "../../utils/string-width";
89

910
const MESSAGE_TIMEOUT_MS = 30000;
1011

@@ -136,7 +137,7 @@ export function DMPanel({
136137
paddingX={1}
137138
>
138139
<Text color="#ffcc00">{isFavorite ? "★" : " "}</Text>
139-
<Text color={isSelected ? theme.fg.accent : theme.fg.primary}>{shortName.slice(0, 5).padEnd(6)}</Text>
140+
<Text color={isSelected ? theme.fg.accent : theme.fg.primary}>{fitVisual(shortName, 5)} </Text>
140141
<Text color={theme.fg.muted}>{shortId}</Text>
141142
{convo.unreadCount > 0 && <Text color={theme.status.online}></Text>}
142143
</Box>
@@ -270,7 +271,7 @@ function MessageRow({ message, nodeStore, isOwn, isSelected, textWidth }: Messag
270271
<Box backgroundColor={isSelected ? theme.bg.selected : undefined}>
271272
<Text wrap="truncate">
272273
<Text color={theme.fg.muted}>[{time}] </Text>
273-
<Text color={nameColor}>{fromName.slice(0, 8).padEnd(9)}</Text>
274+
<Text color={nameColor}>{fitVisual(fromName, 8)} </Text>
274275
<Text color={theme.fg.primary}>{displayText}</Text>
275276
{getStatusIndicator()}
276277
</Text>

src/ui/components/LogPanel.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { theme } from "../theme";
44
import type { NodeStore } from "../../protocol/node-store";
55
import type { DbPositionResponse, DbTracerouteResponse, DbNodeInfoResponse, LogResponse } from "../../db";
66
import { formatNodeId } from "../../utils/hex";
7+
import { fitVisual } from "../../utils/string-width";
78
import { Mesh } from "@meshtastic/protobufs";
89

910
interface LogPanelProps {
@@ -115,7 +116,7 @@ function LogRow({ response, isSelected, nodeStore }: {
115116
<Text>
116117
<Text color={theme.fg.accent}>{prefix}</Text>
117118
<Text color={typeColor}>{type.padEnd(12)}</Text>
118-
<Text color={theme.fg.accent}>{fromName.slice(0, 10).padEnd(12)}</Text>
119+
<Text color={theme.fg.accent}>{fitVisual(fromName, 10)} </Text>
119120
<Text color={theme.fg.secondary}>{time}</Text>
120121
</Text>
121122
);

src/ui/components/NodesPanel.tsx

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,9 @@ import { Box, Text } from "ink";
33
import { theme } from "../theme";
44
import type { NodeData } from "../../protocol/node-store";
55
import { formatNodeId } from "../../utils/hex";
6+
import { stringWidth, truncateVisual, padEndVisual } from "../../utils/string-width";
67
import { Mesh } from "@meshtastic/protobufs";
78

8-
// Calculate visual width of string (emojis = 2, most chars = 1)
9-
function stringWidth(str: string): number {
10-
let width = 0;
11-
for (const char of str) {
12-
const code = char.codePointAt(0) || 0;
13-
// Emoji and wide characters take 2 spaces
14-
// Covers: Misc Technical (23xx), Enclosed Alphanumerics (24xx), Geometric (25xx),
15-
// Misc Symbols (26xx), Dingbats (27xx), Misc Symbols (2Bxx), and SMP emojis (1Fxxx)
16-
if (
17-
code > 0x1F000 ||
18-
(code >= 0x2300 && code <= 0x23FF) ||
19-
(code >= 0x2460 && code <= 0x24FF) ||
20-
(code >= 0x25A0 && code <= 0x25FF) ||
21-
(code >= 0x2600 && code <= 0x27BF) ||
22-
(code >= 0x2B00 && code <= 0x2BFF) ||
23-
(code >= 0x3000 && code <= 0x303F)
24-
) {
25-
width += 2;
26-
} else {
27-
width += 1;
28-
}
29-
}
30-
return width;
31-
}
32-
33-
// Pad string to target visual width
34-
function padEndVisual(str: string, targetWidth: number): string {
35-
const currentWidth = stringWidth(str);
36-
if (currentWidth >= targetWidth) return str;
37-
return str + " ".repeat(targetWidth - currentWidth);
38-
}
39-
409
type NodeSortKey = "hops" | "snr" | "battery" | "time" | "favorites";
4110

4211
interface NodesPanelProps {
@@ -173,19 +142,7 @@ function NodeRow({ node, isSelected }: NodeRowProps) {
173142

174143
const nameColor = node.hopsAway === 0 ? theme.fg.accent : theme.fg.primary;
175144

176-
// Truncate name to ~6 visual chars
177-
let displayName = name;
178-
if (stringWidth(name) > 6) {
179-
let truncated = "";
180-
let w = 0;
181-
for (const char of name) {
182-
const cw = stringWidth(char);
183-
if (w + cw > 6) break;
184-
truncated += char;
185-
w += cw;
186-
}
187-
displayName = truncated;
188-
}
145+
const displayName = truncateVisual(name, 6);
189146

190147
const favStar = node.isFavorite ? "★" : " ";
191148
const hwModel = node.hwModel !== undefined

src/ui/components/PacketList.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { DecodedPacket } from "../../protocol/decoder";
55
import type { NodeStore } from "../../protocol/node-store";
66
import { Mesh, Portnums, Telemetry, StoreForward, Channel, Config } from "@meshtastic/protobufs";
77
import { formatNodeId } from "../../utils/hex";
8+
import { fitVisual } from "../../utils/string-width";
89

910
function LiveIndicator() {
1011
const [frame, setFrame] = useState(0);
@@ -393,9 +394,9 @@ function PacketRow({ packet, nodeStore, isSelected, useFahrenheit }: PacketRowPr
393394
<Text color={theme.data.time}>[{time}] </Text>
394395
<Text color={theme.data.arrow}>{"<"} </Text>
395396
<Text color={color}>{portName.padEnd(14)} </Text>
396-
<Text color={theme.data.nodeFrom}>{fromName.padEnd(10)}</Text>
397+
<Text color={theme.data.nodeFrom}>{fitVisual(fromName, 10)}</Text>
397398
<Text color={theme.data.arrow}>{" -> "}</Text>
398-
<Text color={theme.data.nodeTo}>{toName.padEnd(10)}</Text>
399+
<Text color={theme.data.nodeTo}>{fitVisual(toName, 10)}</Text>
399400
<Text color={theme.fg.muted}>{hops}</Text>
400401
{encryptedInfo}
401402
{renderPacketSummary(packet, nodeStore, useFahrenheit)}

src/utils/string-width.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import stringWidth from "string-width";
2+
3+
// Re-export for convenience
4+
export { default as stringWidth } from "string-width";
5+
6+
// Truncate string to fit within maxWidth visual columns
7+
export function truncateVisual(str: string, maxWidth: number): string {
8+
let width = 0;
9+
let result = "";
10+
for (const { segment: char } of new Intl.Segmenter().segment(str)) {
11+
const charWidth = stringWidth(char);
12+
if (width + charWidth > maxWidth) break;
13+
result += char;
14+
width += charWidth;
15+
}
16+
return result;
17+
}
18+
19+
// Pad string to target visual width with spaces
20+
export function padEndVisual(str: string, targetWidth: number): string {
21+
const currentWidth = stringWidth(str);
22+
if (currentWidth >= targetWidth) return str;
23+
return str + " ".repeat(targetWidth - currentWidth);
24+
}
25+
26+
// Truncate and pad to exact visual width
27+
export function fitVisual(str: string, width: number): string {
28+
return padEndVisual(truncateVisual(str, width), width);
29+
}

0 commit comments

Comments
 (0)