Skip to content

Incremental update misaligns items around large images, rendering ghost text beside image #482

Description

@wojo

Summary

When items are inserted or removed above or below a menu item with a large image= parameter, the incremental diff/patch introduced in b712f7a misaligns item indices. This causes adjacent item text to be rendered as the attributedTitle of the image's NSMenuItem, appearing to the right of the image. The ghost text is interactive (selectable/hoverable) and forces the menu to be wider than intended.

Steps to Reproduce

  1. Save the attached repro script as a SwiftBar plugin (e.g., image-patch-bug.3s.sh)
  2. Open the plugin's menu and keep it open
  3. Wait for a few 3-second refresh cycles

The script outputs a large noisy image (~300KB base64) that changes each refresh, with lines that appear/disappear above and below the image on alternating refreshes.

Expected

All text items should render below (or above) the image, never beside it. The menu width should be determined by the widest text line or the image width.

Actual

After a few incremental updates, text from neighboring menu items appears to the right of the image on the same row. The text is interactive (hoverable/selectable), confirming it is the attributedTitle of the image's NSMenuItem. This widens the menu significantly.

Screenshots

Image Image

Cause

The positional diff in diffMenuNodes() (MenuDiff.swift) compares items by index. When items are inserted or removed, all items after the change point shift indices. The diff treats these as "updates" and patches old NSMenuItems with the wrong new content — e.g., patching an image item with a text item's parameters or vice versa. Since patchMenuItem() sets both attributedTitle and image, the image item can end up with a non-empty title (rendered to the right of the image) or a text item can inherit a stale image.

Introduced in b712f7a ("Reuse menu items instead of recreating them").

Repro Script

#!/bin/bash

# Bug repro: incremental update renders adjacent item text to the RIGHT of an image
# when items above/below the image are inserted or removed between refreshes.
#
# Steps: open this menu and keep it open. Within a few refreshes, text from
# neighboring items appears beside the image instead of below it.

COUNT_FILE="/tmp/swiftbar_patch_bug_count"
COUNT=$(cat "$COUNT_FILE" 2>/dev/null || echo 0)
COUNT=$((COUNT + 1))
echo "$COUNT" > "$COUNT_FILE"

# Large noisy image (~300KB base64) that changes every refresh
IMG=$(/usr/bin/python3 -c "
import base64, struct, zlib, random
random.seed($COUNT)
w, h = 750, 450
def chunk(ctype, data):
    c = ctype + data
    return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
raw = b''
for y in range(h):
    raw += b'\x00'
    for x in range(w):
        raw += bytes([random.randint(0,255), random.randint(0,255), random.randint(0,255)])
sig = b'\x89PNG\r\n\x1a\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
idat = chunk(b'IDAT', zlib.compress(raw, 1))
iend = chunk(b'IEND', b'')
print(base64.b64encode(sig + ihdr + idat + iend).decode())
")

echo "Image patch bug (refresh #\${COUNT})"
echo "---"
echo "Header line (\${COUNT})"
echo "Static line above image"
# Lines that appear/disappear above the image
if (( COUNT % 2 == 0 )); then
    echo "Optional line A (even refreshes only)"
fi
if (( COUNT % 3 == 0 )); then
    echo "Optional line B (every 3rd refresh)"
fi
echo "| image=\${IMG}"
# Lines that appear/disappear below the image
if (( COUNT % 2 == 0 )); then
    echo "Short text below"
else
    echo "Much longer text below the image that varies in width between refreshes to stress layout"
fi
echo "Submenu section"
echo "--Child item \${COUNT}"
if (( COUNT % 3 != 0 )); then
    echo "--Extra child (removed every 3rd refresh)"
fi
echo "---"
echo "Footer (refresh #\${COUNT})"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions