UI & Skin Rendering Guide
SkillMediaCoordinate 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.
No other account needed.
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
PLEDITtiled 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) + 50before 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:
| File | Purpose |
|---|---|
| MAIN.BMP | Main window background |
| CBUTTONS.BMP | Transport buttons |
| TITLEBAR.BMP | Title bar sprites |
| SHUFREP.BMP | Shuffle/repeat/EQ/playlist toggles |
| POSBAR.BMP | Position slider |
| VOLUME.BMP | Volume slider |
| NUMBERS.BMP | Time display digits |
| TEXT.BMP | Marquee font |
| EQMAIN.BMP | Equalizer (275x315) |
| PLEDIT.BMP | Playlist sprites |
| PLEDIT.TXT | Playlist 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...9before 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— verticalleftSideTile(mirrored on the right). The side borders extend tobounds.height, INCLUDING the bottom-corner regions, so the outer gold trim runs continuously down each side.drawPlaylistStyleBottomBorder— rotatedleftSideTilestrip inset between the side borders (x = 12tobounds.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
leftCornersprite (not by drawing the originalrightCorner). The originalrightCornerartwork 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 originalrightCornerare re-drawn on top from sprite coords(167, 3, 9, 9)and(158, 3, 9, 9)(with+21y 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-menuNSMenuIteminstances. - 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. - Keep entries in the menu that owns their action, not merely the object they configure. In
.winampModern, Text Size is adjacent to UI Size in Windows, and named skin-owned windows form a flat section after NullPlayer-owned windows; neither belongs in Skins. Filter the skin block through the reconciled surface catalog so playlist, EQ, library, video, and visualization never acquire duplicate rows. NullPlayer's Compact Mode and Compact Window are omitted because a.walskin owns its compact/shade layouts. - For Sonos room selection UX, use
SonosRoomCheckboxViewwhen persistent-open submenu behavior is required. - For library-browser column visibility menus, use
ColumnVisibilityCheckboxViewfor persistent-open checkbox rows. Keep column preferences mode-scoped: Modern usesBrowserVisible*Columns; Classic usesClassicBrowserVisible*Columns.
Dockable Center-Stack Windows
Main, EQ, Playlist, Spectrum, Waveform, Audio Analysis, PeppyMeter, Flow, and Sonos Rooms 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; Sonos Rooms uses a double-height baseline and preserves a taller restored list
- Saved frames are restored through
WindowManagerrather 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 withindockThresholdplus 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/soWindowManagercan 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 onborderWidth > 0 && !adjacentEdges.isEmptyand 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:
- Shared non-UI logic in a neutral folder (for example
Waveform/) - Classic chrome in
Windows/... - Modern chrome in
Windows/Modern... - Registration and docking behavior in
WindowManager
Sonos Rooms uses a shared controller/content view with separate Original/Metal chrome and a
.wal hosted-surface adapter. Its scrollable list must leave Refresh and casting controls in a
fixed footer. Explicitly lay out controls on first show and resize, including when the room list
is empty, and match native AppKit control appearance to the skin's background. The implementation,
volume semantics, lifecycle, and rendering fixtures are owned by the
Sonos casting skill.
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:
- Close button → set pressed state,
return. - Any
clickCount == 2action (e.g. Flow's direction toggle, Spectrum's quality cycle) →return. This must come before the fallthrough or it becomes dead code. - Title bar → start a title-bar drag,
return. - 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/windowDragStartPointstate set inmouseDownmouseDraggedthat moves the window origin throughWindowManager.shared.windowWillMove(_:to:)mouseUpthat callsWindowManager.shared.windowDidFinishDragging(_:)and clearsisDraggingWindow
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.lastPlexBrowserFramecaches the frame on every hide/close.togglePlexBrowser()caches beforeorderOut; both controllers'windowWillClosecallrememberPlexBrowserFrameBeforeClose()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 returnswindow.frame. - Do not leak across UI mode switches:
teardownModeDependentWindows()clearslastPlexBrowserFrameafter 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 fromrecreateModeDependentLayout's snapshot frame. - Persistence (
AppStateManager): saveswm.plexBrowserFrameForPersistence— the live controller frame even whenorderOut-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=12selects bottom half (active)sourceRect y=0selects 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:
| States | Source region |
|---|---|
| 0-13 | x = 13 + state * 15, y = 164, 15x63 |
| 14-27 | x = 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
}
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 118
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
ui-guide- Source
- github.com/ad-repo/nullplayer