UI & Skin Rendering Guide

SkillMedia

Coordinate systems, scaling architecture, hit testing, skin sprite rendering, window layout, and Compact Mode for NullPlayer's UI. Use when working on window scaling, skin rendering, coordinate transforms, visual layout, compact/status-item windows, or playlist/marquee text rendering.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the UI & Skin Rendering Guide skill

What this skill tells your AI

The instructions your AI receives, as published by ad-repo/nullplayer in skills/ui-guide/SKILL.md and read by ahel’s review.

Reference for working on NullPlayer's skin-style UI and skin system.

Coordinate Systems

Winamp: Y=0 at top, Y increases downward macOS: Y=0 at bottom, Y increases upward

Apply this transform before drawing:

context.translateBy(x: 0, y: bounds.height)
context.scaleBy(x: 1, y: -1)

Text requires counter-flip (NSString draws upside-down after the transform):

context.saveGState()
let centerY = textY + fontSize / 2
context.translateBy(x: 0, y: centerY)
context.scaleBy(x: 1, y: -1)
context.translateBy(x: 0, y: -centerY)
text.draw(at: NSPoint(x: textX, y: textY), withAttributes: attrs)
context.restoreGState()

Scaling Architecture

NullPlayer uses two different resize modes for windows:

Scaling Mode (Main Window, EQ)

Windows scale via context transform. Draw at original size, everything gets bigger/smaller proportionally:

var scaleFactor: CGFloat {
    bounds.width / originalWindowSize.width
}

override func draw(_ dirtyRect: NSRect) {
    let scale = scaleFactor
    context.translateBy(x: 0, y: bounds.height)
    context.scaleBy(x: 1, y: -1)

    // Use low interpolation for clean sprite scaling on large monitors
    // .none causes artifacts, .high causes blur
    // NOTE: For non-Retina specific fixes, see non-retina-fixes skill
    context.interpolationQuality = .low

    if scale != 1.0 {
        let scaledWidth = originalWindowSize.width * scale
        let scaledHeight = originalWindowSize.height * scale
        let offsetX = (bounds.width - scaledWidth) / 2
        let offsetY = (bounds.height - scaledHeight) / 2
        context.translateBy(x: offsetX, y: offsetY)
        context.scaleBy(x: scale, y: scale)
    }

    let drawBounds = NSRect(origin: .zero, size: originalWindowSize)
    // Draw using drawBounds, NOT bounds
}

Stretch Expansion Mode (Playlist, Spectrum, Waveform)

Center-stack secondary windows now support horizontal and vertical stretching:

  • Playlist, Spectrum, and Waveform use skin minimum sizes and maxSize = .greatestFiniteMagnitude
  • Default open width still aligns to main window width
  • Reopen without a saved frame resets to default docked frame below main
  • Restored classic frames preserve width for windows that support stretch (playlist + waveform)

For classic playlist rendering, UI scale is derived from the main-window UI Size level, not the stretched playlist width. This keeps bitmap text and chrome stable while allowing wider windows:

private var scaleFactor: CGFloat {
    if let mainWidth = WindowManager.shared.mainWindowController?.window?.frame.width,
       mainWidth > 0 {
        return mainWidth / Skin.baseMainSize.width
    }
    return Skin.scaleFactor * WindowManager.shared.classicScaleMultiplier
}

Anti-pattern (regression source):

  • Letting classic playlist width stretch freely while deriving scale from a different source can create fractional skin-space widths.
  • With PLEDIT tiled title bars, fractional widths produce visible section seams/line artifacts in the top decorative bar.

Safe pattern:

  • Derive classic playlist render scale from current main-window width.
  • Snap classic playlist width in skin space to width = (N * 25) + 50 before applying frame updates.

effectiveWindowSize expands in both dimensions in skin space:

private var effectiveWindowSize: NSSize {
    let scale = scaleFactor
    let effectiveWidth = bounds.width / scale
    let effectiveHeight = bounds.height / scale
    return NSSize(width: effectiveWidth, height: max(originalWindowSize.height, effectiveHeight))
}

Hit Testing (Scaling Mode)

Convert view coordinates to skin coordinates:

private func convertToWinampCoordinates(_ point: NSPoint) -> NSPoint {
    let scale = scaleFactor
    let scaledWidth = originalWindowSize.width * scale
    let scaledHeight = originalWindowSize.height * scale
    let offsetX = (bounds.width - scaledWidth) / 2
    let offsetY = (bounds.height - scaledHeight) / 2

    let unscaledX = (point.x - offsetX) / scale
    let unscaledY = (point.y - offsetY) / scale
    let winampY = originalWindowSize.height - unscaledY

    return NSPoint(x: unscaledX, y: winampY)
}

Skin File Structure

.wsz files are ZIP archives containing:

FilePurpose
MAIN.BMPMain window background
CBUTTONS.BMPTransport buttons
TITLEBAR.BMPTitle bar sprites
SHUFREP.BMPShuffle/repeat/EQ/playlist toggles
POSBAR.BMPPosition slider
VOLUME.BMPVolume slider
NUMBERS.BMPTime display digits
TEXT.BMPMarquee font
EQMAIN.BMPEqualizer (275x315)
PLEDIT.BMPPlaylist sprites
PLEDIT.TXTPlaylist colors

BMP Parsing

Classic skin BMPs may be 1-bit, 4-bit, 8-bit, 24-bit, or 32-bit. Row stride must be computed in bits and then aligned to 4 bytes:

let rowSize = ((width * bitsPerPixel + 31) / 32) * 4

Do not approximate packed formats as max(1, bitsPerPixel / 8) bytes per pixel. That treats 4-bit skins as one byte per pixel and misaligns every row after the first, which scrambles 16-color skin sprites such as CBUTTONS.BMP, NUMBERS.BMP, and PLEDIT.BMP.

Sprite Drawing

Sprites are defined in SkinElements.swift and drawn via SkinRenderer:

private func drawSprite(from image: NSImage, sourceRect: NSRect, to destRect: NSRect, in context: CGContext) {
    guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
    let flippedY = image.size.height - sourceRect.origin.y - sourceRect.height
    let sourceInCG = CGRect(x: sourceRect.origin.x, y: flippedY, width: sourceRect.width, height: sourceRect.height)
    if let cropped = cgImage.cropping(to: sourceInCG) {
        context.draw(cropped, in: destRect)
    }
}

Classic Time Display

Classic main-window skins bake the colon into MAIN.BMP, while NUMBERS.BMP supplies 9×13 digit sprites. Normal MM:SS times use the two standard minute cells at x=48 and x=60.

For times of 100 minutes or more, keep the full MMM:SS value and seconds:

  • Draw the leading minute digit at x=36, reusing the normal minus-sign slot.
  • Keep the remaining minute digits at x=48 and x=60 so all three use native-size skin art.
  • In remaining mode, draw the minus at x=24 and shift the playback-status icon from x=26 to x=14.
  • Only pathological 4+ digit minute values compress within the expanded three-cell field.
  • Clamp every sprite digit index to 0...9 before calculating its source rectangle.

Do not convert long classic times to H:MM; users need the seconds, and the unused left-side space makes a native-size third minute digit possible.

Tile-Aligned Widths

Windows using PLEDIT tiles (25px) must have tile-aligned widths to avoid artifacts:

Width = (N * 25) + 50  (50 = left corner + right corner)

Valid widths: 275, 300, 425, 450, 475, 500, 550px

Non-Retina displays: Even with aligned widths, tile seams may be visible on 1x displays. See the non-retina-fixes skill for techniques like background fill, tile overlap, and bottom-to-top drawing.

Classic Playlist-Style Window Chrome

drawPlaylistWindow / drawSpectrumAnalyzerWindow / drawProjectMNormal / drawPlexBrowserWindow all share three helpers in SkinRenderer and render as one continuous U-shape outline:

  • drawPlaylistStyleSideBorders — vertical leftSideTile (mirrored on the right). The side borders extend to bounds.height, INCLUDING the bottom-corner regions, so the outer gold trim runs continuously down each side.
  • drawPlaylistStyleBottomBorder — rotated leftSideTile strip inset between the side borders (x = 12 to bounds.width − 12), plus a 2px-tall gold-trim row tiled across the FULL window width at the very bottom. The gold trim is cropped from the rotated tile's bottom 2 rows, which come from the source's gold-bevel column, so every pixel matches the side borders' outer trim color.
  • Top-right corner fix — the right corner of the title bar is rendered by MIRRORING the leftCorner sprite (not by drawing the original rightCorner). The original rightCorner artwork was designed to abut the legacy 20-wide scrollbar tile, so its inner bevel sits too far inward and leaves the interior content area visibly wider under the title bar than below. The close (and shade, where applicable) button icons baked into the original rightCorner are re-drawn on top from sprite coords (167, 3, 9, 9) and (158, 3, 9, 9) (with +21 y offset for the inactive state).

Bottom-border thickness lives in layout structs: Playlist.bottomHeight, SpectrumWindow.Layout.bottomBorder, WaveformWindow.Layout.bottomBorder, ProjectM.Layout.bottomBorder, PlexBrowser.Layout.statusBarHeight, and LibraryWindow.Layout.statusBarHeight are all 7 * Skin.scaleFactor. Interior content rendering uses these constants, so keep them in sync if you change the strip height.

Pixel snapping: Both helpers snap tile destinations with .rounded(.down) to avoid sub-pixel rendering that bleeds the default Winamp skin's blue-tinted edge pixels between adjacent tiles. See non-retina-fixes skill for the underlying issue.

Menu Bar Integration (AppKit)

When adding or refactoring top menu bar content:

  • Build dedicated menu-bar trees (buildMenuBar*) instead of reusing context-menu NSMenuItem instances.
  • Avoid NSMenuItem.copy() for action-bearing items; copied items can lose expected target/action behavior in this app.
  • Keep side effects (network discovery, long-running work) out of menu construction.
  • Prefer lifecycle startup for services and menuNeedsUpdate(_:) for state refresh when a menu opens.
  • For Sonos room selection UX, use SonosRoomCheckboxView when persistent-open submenu behavior is required.
  • For library-browser column visibility menus, use ColumnVisibilityCheckboxView for persistent-open checkbox rows. Keep column preferences mode-scoped: Modern uses BrowserVisible*Columns; Classic uses ClassicBrowserVisible*Columns.

Dockable Center-Stack Windows

Main, EQ, Playlist, Spectrum, Waveform, Audio Analysis, PeppyMeter, and Flow all participate in the center stack managed by WindowManager.

  • Width is normalized to the main stack
  • Height is window-specific: Flow is single-height; PeppyMeter uses a 1.75x landscape height
  • Saved frames are restored through WindowManager rather than ad hoc per-window logic
  • Opening a center-stack window must calculate gaps from windows actually docked below main, not every visible stack-capable window. Use dockedCenterStackWindowsBelowMain(mainFrame:) (vertical adjacency within dockThreshold plus horizontal overlap) so detached windows moved aside do not make new windows drift downward below the floating stack.
  • Modern and classic implementations should expose a provider protocol in App/ so WindowManager can manage both without mode-specific branching outside window creation
  • Modern dockable windows must not create a second visual border by adding their own outer content gutter or rounded inner panel around the main content. Use the shared auxiliary chrome inset (ModernSkinElements.*BorderWidth) as the only window border. If content needs internal breathing room, apply it inside the renderer/content layout, not by shrinking the whole chrome content rect. Flow and PeppyMeter are explicit regression examples: extra content padding made their modern windows look like they had heavy borders, while Metal used the correct thin-edge treatment.
  • A dockable window that draws its own content rect (rather than hosting a child view that fills the content area) must pass that rect through NSRect.expandingThroughJoinedEdges(in:borderWidth:adjacentEdges:) before drawing — in every render style (classic, modern, metal). On any edge docked to a neighbor the shared border is suppressed (modern seamless docking, Metal's thin border, classic flush docking); without the content bleed a ~1px background strip shows through as a hairline seam on 1x displays. This was issue #364 (PeppyMeter/Flow): the helper originally short-circuited for non-metal render styles, so only Metal was immune. The helper self-guards on borderWidth > 0 && !adjacentEdges.isEmpty and only expands across small edge-adjacent chrome/border gaps, so it is a no-op on non-docked edges and will not jump body content across a visible title bar. Windows that host a child view filling the content area do not need this.
  • Animated dockable windows whose content reaches a joined edge need a separate repaint guard. Do not blindly copy PeppyMeter's setNeedsDisplay(contentAreaRect()) content-only redraw if the content is flush with a chrome or docked edge. Invalidate a smaller animation rect that excludes the edge strip, clip drawing to that rect, and still pass the stable full content rect into the renderer. Full paints should draw animated content before chrome/borders so the chrome owns the final edge pixels. Flow's bottom-edge flicker when locked above Waveform was the regression that proved this rule.

For new center-stack windows, follow the waveform/spectrum pattern:

  1. Shared non-UI logic in a neutral folder (for example Waveform/)
  2. Classic chrome in Windows/...
  3. Modern chrome in Windows/Modern...
  4. Registration and docking behavior in WindowManager

Window Dragging (MUST)

A center-stack window's mouseDown must end with a content-area fallthrough that starts a window drag for any click that did not hit an interactive region — the close button, sliders, playlist rows, a seek/scrub area, or a body that is itself a click target. Windows whose body is a control are the exceptions and do not whole-face drag: Waveform's body is a scrub area, and ProjectM drags only from its top-quarter zone because the lower body opens the preset-ratings overlay. For every other "plain display" window (Spectrum, Flow, Audio Analysis, PeppyMeter) the whole face drags.

Use SpectrumView.mouseDown as the canonical implementation. Order the checks:

  1. Close button → set pressed state, return.
  2. Any clickCount == 2 action (e.g. Flow's direction toggle, Spectrum's quality cycle) → return. This must come before the fallthrough or it becomes dead code.
  3. Title bar → start a title-bar drag, return.
  4. Fallthrough → start a window drag for the remaining face.

Dragging is not just mouseDown. A draggable window also needs, mirroring SpectrumView:

  • override func acceptsFirstMouse(...) -> Bool { true }
  • isDraggingWindow / windowDragStartPoint state set in mouseDown
  • mouseDragged that moves the window origin through WindowManager.shared.windowWillMove(_:to:)
  • mouseUp that calls WindowManager.shared.windowDidFinishDragging(_:) and clears isDraggingWindow

Pass WindowManager.shared.windowWillStartDragging(window, fromTitleBar:) to begin the drag. The fromTitleBar argument is currently inert (the drag logic ignores it); pass whatever documents the click origin — title-bar branches pass true, and the fallthrough conventionally passes the window's Hide-Title-Bars state (hideTitleBars classic / effectiveHideTitleBars(for:) modern). Do not agonize over the value; it does not change behavior today.

Library Window Position Memory

The Library/browser window is not a center-stack window — it does not snap back into the column below the main window. Instead it remembers where the user last put it (issue #326):

  • WindowManager.lastPlexBrowserFrame caches the frame on every hide/close. togglePlexBrowser() caches before orderOut; both controllers' windowWillClose call rememberPlexBrowserFrameBeforeClose() for the red-button path.
  • showPlexBrowser(at:) applies a priority chain: explicit restored frame (launch / mode rebuild) → remembered session frame → default right-of-stack layout (first-ever open only).
  • Always capture the frame via LibraryBrowserWindowProviding.frameForPositionMemory, which returns window.frame.
  • Do not leak across UI mode switches: teardownModeDependentWindows() clears lastPlexBrowserFrame after nil'ing the controller so a classic frame can't apply to the modern window (or vice-versa). An open library that must survive the switch is repositioned explicitly from recreateModeDependentLayout's snapshot frame.
  • Persistence (AppStateManager): saves wm.plexBrowserFrameForPersistence — the live controller frame even when orderOut-hidden (fixes Compact Mode) or the last remembered frame (closed at quit). On restore, seedPlexBrowserFrame(_:) primes the cache when the library was not reopened, so the first open uses the saved position.

Custom Sprites

For on/off states, stack vertically in NSImage:

  • y=0-11: ON state (active)
  • y=12-23: OFF state (inactive)

Due to coordinate flipping:

  • sourceRect y=12 selects bottom half (active)
  • sourceRect y=0 selects top half (inactive)
let sourceRect = isActive ?
    NSRect(x: 0, y: 12, width: 27, height: 12) :
    NSRect(x: 0, y: 0, width: 27, height: 12)

Classic EQ Skin Art

The classic EQ should use EQMAIN.BMP for themed slider and graph art, not hardcoded color bars.

Slider tracks use the 28-state spline sprites in EQMAIN.BMP:

StatesSource region
0-13x = 13 + state * 15, y = 164, 15x63
14-27x = 13 + (state - 14) * 15, y = 229, 15x63

Map EQ values with state = round(normalizedValue * 27), where normalizedValue = (value + 12) / 24. State 0 is lowest/cut (green in the default skin), and state 27 is highest/boost (red in the default skin). Draw the track sprite first, then draw the 11x11 thumb (x=0, y=164) on top. Before drawing a skin-art track, validate that the full source rect is present in EQMAIN.BMP. Some placeholder or partial skins omit the extended 315px EQ art region; those must fall back to the programmatic slider track/knob instead of stretching a partial crop or silently drawing nothing.

The graph well is already part of the EQMAIN.BMP background (0,0,275,116). Do not paint a separate black background, grid, or border over it. The classic graph curve samples its color ramp from the 1x19 vertical gradient at EQMAIN.BMP coordinate (115,294), with top = +12 dB and bottom = -12 dB. This keeps non-default skins (for example purple or monochrome EQ themes) visually consistent with their source artwork. If that gradient is absent, keep the built-in fallback color ramp.

Playlist Text Rendering

The playlist window renders all text using the same bitmap font (TEXT.BMP) as the main window, ensuring visual consistency across the application.

Implementation

Located in Windows/Playlist/PlaylistView.swift:

// All playlist text uses bitmap font from TEXT.BMP
// CGImage is cached outside draw cycle to prevent cross-window interference
private var cachedTextBitmapCGImage: CGImage?

private func cacheTextBitmapCGImage() {
    guard let skin = WindowManager.shared.currentSkin,
          let textImage = skin.text else { return }
    cachedTextBitmapCGImage = textImage.cgImage(forProposedRect: nil, context: nil, hints: nil)
}

// Characters are drawn using CGContext with proper coordinate flipping
private func drawBitmapText(_ text: String, at position: NSPoint, in context: CGContext, skin: Skin?, isSelected: Bool = false) {
    // Crop each character from cached CGImage
    // Apply Y-flip for CGContext coordinate system
    // For selected tracks, convert green pixels to white
}

Marquee Scrolling

The currently playing track marquees when its title is too long:

// Timer-based marquee offset (8Hz update rate)
private var marqueeOffset: CGFloat = 0
private var currentTrackTextWidth: CGFloat = 0

// In drawTrackText(), current track uses marqueeOffset for scrolling
let xOffset = needsMarquee ? -marqueeOffset : 0

Unicode Fallback

The bitmap font (TEXT.BMP) only supports ASCII characters. Track titles with Japanese, Chinese, Korean, Cyrillic, Arabic, or other non-Latin characters are automatically detected and rendered using system font fallback, matching the behavior of the main window marquee.

private func containsNonLatinCharacters(_ text: String) -> Bool {
    // Returns true if text contains characters outside A-Z, 0-9, and common symbols
}

This ensures:

  • Latin text: Uses skin bitmap font for authentic look
  • Non-Latin text: Falls back to system font for proper Unicode display
  • Mixed text: Falls back to system font if any non-Latin characters present

Selected Track Appearance

Selected tracks display white text instead of green. This uses pixel manipulation:

private func convertToWhite(_ charImage: CGImage, charWidth: Int, charHeight: Int) -> CGImage? {
    // Convert green (0, G, 0) pixels to white (G, G, G)
    // Magenta (255, 0, 255) pixels are treated as transparent
}

Auto-Selection

When the playlist opens while music is playing, the current track is auto-selected:

override func viewDidMoveToWindow() {
    // Auto-select the currently playing track when playlist opens
    if selectedIndices.isEmpty && engine.currentIndex >= 0 {
        selectedIndices = [engine.currentIndex]
    }
}

Cross-Window Interference Prevention

A key issue was NSImage.cgImage() affecting shared graphics state during render cycles, causing the main window marquee to switch fonts when the playlist scrolled.

Solution: Cache CGImage representation of TEXT.BMP outside of draw cycles, called only when skin changes or view initializes.

Main Window Marquee

The main window uses a scrolling marquee to display the current track title.

Skin Bitmap Font

By default, the marquee uses the skin's TEXT.BMP bitmap font, which provides the authentic Winamp look. This font only supports:

  • A-Z (case-insensitive)
  • 0-9
  • Common symbols: " @ : ( ) - ' ! _ + \ / [ ] ^ & % . = $ # ? *

Unicode Fallback

When track titles contain characters not supported by the skin font (Japanese, Cyrillic, Chinese, Korean, accented characters, etc.), the marquee automatically falls back to system font rendering:

private func containsNonLatinCharacters(_ text: String) -> Bool {
    for char in text {
        switch char {
        case "A"..."Z", "a"..."z", "0"..."9":
            continue
        case " ", "\"", "@", ":", "(", ")", "-", "'", "!", "_", "+", "\\", "/",
             "[", "]", "^", "&", "%", ".", "=", "$", "#", "?", "*":
            continue
        default:
            return true  // Non-Latin character detected
        }
    }
    return false
}

This ensures:

  • Latin text: Uses skin bitmap font for authentic look
  • Non-Latin text: Falls back to system font for proper Unicode display
  • Mixed text: Falls back to system font if any non-Latin characters present

The system font fallback maintains the green color and scrolling behavior, just with full Unicode support.

White Text Rendering

Some UI elements (like library/server names in the browser) require white text instead of the standard green skin font. This is implemented in SkinRenderer.drawSkinTextWhite().

Implementation

White text is rendered using an offscreen buffer approach to avoid blend mode artifacts:

// For each character:
// 1. Crop character from TEXT.BMP
// 2. Draw to small offscreen CGContext at 1x scale
// 3. Convert pixels: green channel (0, G, 0) → white (G, G, G)
// 4. Draw result to main context

for i in 0..<(charWidth * charHeight) {
    let offset = i * 4
    let g = pixels[offset + 1]  // Green channel = brightness

    // Skip transparent and magenta background pixels
    if a == 0 || isMagenta { continue }

    // Convert to white using green channel as brightness
    pixels[offset] = g     // R
    pixels[offset + 1] = g // G
    pixels[offset + 2] = g // B
}

Why Not Blend Modes?

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
120
Forks
9
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
ui-guide
Source
github.com/ad-repo/nullplayer