MioIsland/ClaudeIsland/UI/Views/NotchCustomizationSettingsView.swift
徐翔宇 b2f3a3554f feat: System Settings redesign + Notch theme reset + Buddy style picker
Settings window (SystemSettingsView):
- Graphite dark palette (#201f27 sidebar / #1c1c1e detail) replacing the
  lime L-shape, per Claude Design reference bundle. Lime survives only
  as an accent on toggles, icons, and active pills.
- macOS-style titlebar with real traffic lights (red close / yellow
  hide / green decorative) + centered "系统设置".
- Tabs get large H1 + English subtitle ("通用  General preferences").
- New primitives: SectionLabel, SettingsListCard, SettingRow
  (icon tile + label + sublabel + control), InfoRow (pos/neg/hint
  colored dots), IOSToggle (pill slider).
- General tab rewritten: stacked rows with dividers, 3 toggles with
  sublabels, proxy card with ✓/✕/i info rows, language Menu picker,
  accessibility status row.
- Bottom sidebar "返回" → "退出" calling NSApplication.terminate.
- Window resized 720×560 → 960×720 (1.33:1) for reference-mock breathing
  room. Still fixed-size (borderless).
- TextField placeholder: ZStack overlay with solid light gray, since
  SwiftUI TextField.prompt ignores foregroundColor on macOS. Applied to
  both the Anthropic proxy field and the Install-from-URL field.

Notch themes (NotchTheme / NotchCustomization):
- Reset to 7 themes: Classic + Forest / Neon Tokyo / Sunset /
  Retro Arcade / High Contrast / Sakura (ported from Claude Design
  themes.jsx palettes). Dropped: paper, neonLime, cyber, mint, rosegold,
  ocean, aurora, mocha, lavender, cherry.
- Graceful decode: try? c.decode(NotchThemeID) so legacy raw values
  fall back to .classic instead of throwing.
- NotchPalette gains `accent` field. NotchView.statusDotColor and
  badgeColor use accent for .idle, so at-rest notch reflects the theme
  instead of hardcoded 30%-white (invisible on light-bg themes).
- Theme picker replaced Menu dropdown with 2-column grid of preview
  cards, each rendering a mini pill in that theme's own colors. Selected
  card borders/glows in its own accent.

Buddy style (NotchCustomization.BuddyStyle):
- New `buddyStyle: BuddyStyle` field with two cases: .pixelCat, .emoji.
  Evaluated a .neon option via NeonPixelCatView but it degrades to a
  green blob at 16×16; pulled from the picker pending a small-size
  renderer.
- Migration: missing buddyStyle decodes by reading the legacy
  usePixelCat AppStorage bool. Picker writes back to usePixelCat so
  unmigrated call sites (ClaudeInstancesView) stay in sync.
- Old "Pixel Cat Mode" toggle removed from Appearance tab — the new
  segmented picker in the Notch section supersedes it.

Plugin panel size hint for built-ins:
- LoadedPlugin.preferredPanelSize checks an @objc runtime method first,
  then falls back to Info.plist (gated on bundle !== Bundle.main so
  built-ins don't accidentally read the host's plist).
- PairPhonePlugin declares @objc preferredPanelSize() = 340×480, then
  bumped to 440×480 to match the music plugin's width.

Tests:
- NotchThemeTests / NotchCustomizationTests / NotchCustomizationStoreTests
  updated to new theme line-up plus a regression guard for the legacy
  theme ID → .classic fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:25:15 +08:00

467 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// NotchCustomizationSettingsView.swift
// ClaudeIsland
//
// Settings UI surface for the notch customization feature, embedded
// inside the Appearance tab of `SystemSettingsView` (wrapped in a
// `SettingsCard` with the section title). Renders only the inner
// rows no padding, background, or section header of its own
// so the visual style matches the surrounding cards exactly.
//
// The visual constants here are intentionally kept in sync with
// `SystemSettingsView`'s private `Theme` enum (font sizes 12 for
// labels, 12 for icons, sidebarFill = #CAFF00 for the lime accent,
// inner row corner radius 7) so the rows look identical to the
// TabToggle / SettingsCard rows in the rest of the popup.
//
// Spec: docs/superpowers/specs/2026-04-08-notch-customization-design.md
// sections 4.1, 4.5, 4.6.
//
import SwiftUI
struct NotchCustomizationSettingsView: View {
@ObservedObject private var store: NotchCustomizationStore = .shared
private static let brandLime = Color(red: 0xCA/255, green: 0xFF/255, blue: 0x00/255)
var body: some View {
// The enclosing SettingsCard already provides the title,
// padding, background, and border. We just emit the rows.
VStack(alignment: .leading, spacing: 8) {
themeSection
buddyStyleRow
fontSizeRow
hoverSpeedRow
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
visibilityToggle(
icon: "sparkles",
label: L10n.notchShowBuddy,
isOn: store.customization.showBuddy
) {
store.update { $0.showBuddy.toggle() }
}
.accessibilityLabel(L10n.notchShowBuddy)
visibilityToggle(
icon: "chart.bar.fill",
label: L10n.notchShowUsageBar,
isOn: store.customization.showUsageBar
) {
store.update { $0.showUsageBar.toggle() }
}
.accessibilityLabel(L10n.notchShowUsageBar)
}
hardwareModeRow
customizeButton
}
}
// MARK: - Theme picker grid of preview cards
/// Header row + 2-column grid of theme cards. Each card shows a mini
/// capsule rendered in the target theme's own colors so the user can
/// judge the palette at a glance, not just "green dot says Forest".
/// Selected card glows with its own accent each theme announces
/// itself the way the Claude Design mock does.
private var themeSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: "paintpalette")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.5))
.frame(width: 16)
Text(L10n.notchTheme)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.7))
Spacer()
Text(L10n.notchThemeName(store.customization.theme))
.font(.system(size: 11, weight: .medium))
.foregroundColor(NotchPalette.for(store.customization.theme).accent)
}
.padding(.horizontal, 2)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
],
spacing: 8
) {
ForEach(NotchThemeID.allCases) { id in
ThemePreviewCard(
themeID: id,
isSelected: store.customization.theme == id
) {
store.update { $0.theme = id }
}
}
}
}
.padding(.bottom, 4)
}
// MARK: - Buddy style segmented picker row
/// Two-way segmented picker for which sprite appears in the notch:
/// pixel cat (always available) / Claude Code companion emoji. Emoji
/// needs `~/.claude.json` to have `companion` data or it falls back
/// to the pixel cat at render time. The pick also mirrors into the
/// legacy `usePixelCat` AppStorage so ClaudeInstancesView (which
/// hasn't been migrated) stays roughly in sync.
private var buddyStyleRow: some View {
controlRow(icon: "cat", label: L10n.notchBuddyStyle) {
HStack(spacing: 0) {
buddyStyleSegment(.pixelCat, shortLabel: L10n.notchBuddyPixelCat)
buddyStyleSegment(.emoji, shortLabel: L10n.notchBuddyEmoji)
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06))
)
}
}
private func buddyStyleSegment(
_ style: BuddyStyle,
shortLabel: String
) -> some View {
let isSelected = store.customization.buddyStyle == style
return Button {
store.update { $0.buddyStyle = style }
// Keep legacy AppStorage in sync so unmigrated call sites
// (ClaudeInstancesView) still render something sensible.
UserDefaults.standard.set(style == .pixelCat, forKey: "usePixelCat")
} label: {
Text(shortLabel)
.font(.system(size: 11, weight: isSelected ? .bold : .medium))
.foregroundColor(isSelected ? .black : .white.opacity(0.7))
.frame(minWidth: 36)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isSelected ? Self.brandLime : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityLabel(shortLabel)
}
// MARK: - Font size segmented picker row
private var fontSizeRow: some View {
controlRow(icon: "textformat.size", label: L10n.notchFontSize) {
HStack(spacing: 0) {
fontSizeSegment(.small, shortLabel: L10n.notchFontSmall, accessibilityLabel: L10n.notchFontSmallFull)
fontSizeSegment(.default, shortLabel: L10n.notchFontDefault, accessibilityLabel: L10n.notchFontDefaultFull)
fontSizeSegment(.large, shortLabel: L10n.notchFontLarge, accessibilityLabel: L10n.notchFontLargeFull)
fontSizeSegment(.xLarge, shortLabel: L10n.notchFontXLarge, accessibilityLabel: L10n.notchFontXLargeFull)
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06))
)
}
}
private func fontSizeSegment(
_ scale: FontScale,
shortLabel: String,
accessibilityLabel: String
) -> some View {
Button {
store.update { $0.fontScale = scale }
} label: {
Text(shortLabel)
.font(.system(size: 11, weight: store.customization.fontScale == scale ? .bold : .medium))
.foregroundColor(store.customization.fontScale == scale ? .black : .white.opacity(0.7))
.frame(minWidth: 26)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.customization.fontScale == scale ? Self.brandLime : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
}
// MARK: - Hover speed segmented picker row
private var hoverSpeedRow: some View {
controlRow(icon: "cursorarrow.motionlines", label: L10n.notchHoverSpeed) {
HStack(spacing: 0) {
hoverSpeedSegment(.instant, shortLabel: L10n.notchHoverInstant)
hoverSpeedSegment(.normal, shortLabel: L10n.notchHoverNormal)
hoverSpeedSegment(.slow, shortLabel: L10n.notchHoverSlow)
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06))
)
}
}
private func hoverSpeedSegment(
_ speed: HoverSpeed,
shortLabel: String
) -> some View {
Button {
store.update { $0.hoverSpeed = speed }
} label: {
Text(shortLabel)
.font(.system(size: 11, weight: store.customization.hoverSpeed == speed ? .bold : .medium))
.foregroundColor(store.customization.hoverSpeed == speed ? .black : .white.opacity(0.7))
.frame(minWidth: 30)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(store.customization.hoverSpeed == speed ? Self.brandLime : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityLabel(shortLabel)
}
// MARK: - Visibility toggle (TabToggle-style)
private func visibilityToggle(
icon: String,
label: String,
isOn: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 12))
.foregroundColor(.white.opacity(isOn ? 0.9 : 0.5))
.frame(width: 16)
Text(label)
.font(.system(size: 12, weight: isOn ? .semibold : .medium))
.foregroundColor(.white.opacity(isOn ? 0.95 : 0.7))
.lineLimit(1)
Spacer(minLength: 0)
Circle()
.fill(isOn ? Self.brandLime : Color.white.opacity(0.18))
.frame(width: 7, height: 7)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(isOn ? Self.brandLime.opacity(0.10) : Color.white.opacity(0.03))
)
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(
isOn ? Self.brandLime.opacity(0.25) : Color.white.opacity(0.08),
lineWidth: 0.5
)
)
}
.buttonStyle(.plain)
}
// MARK: - Hardware mode picker row
private var hardwareModeRow: some View {
controlRow(icon: "laptopcomputer", label: L10n.notchHardwareMode) {
Menu {
Button(L10n.notchHardwareAuto) {
store.update { $0.hardwareNotchMode = .auto }
}
Button(L10n.notchHardwareForceVirtual) {
store.update { $0.hardwareNotchMode = .forceVirtual }
}
} label: {
HStack(spacing: 6) {
Text(
store.customization.hardwareNotchMode == .auto
? L10n.notchHardwareAuto
: L10n.notchHardwareForceVirtual
)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.95))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9))
.foregroundColor(.white.opacity(0.5))
}
}
.buttonStyle(.plain)
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel(L10n.notchHardwareMode)
}
}
// MARK: - Customize button full-width prominent action
private var customizeButton: some View {
Button {
store.enterEditMode()
} label: {
HStack(spacing: 8) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 12, weight: .semibold))
Text(L10n.notchCustomizeButton)
.font(.system(size: 12, weight: .semibold))
Spacer(minLength: 0)
Image(systemName: "arrow.right")
.font(.system(size: 11, weight: .semibold))
.opacity(0.85)
}
.foregroundColor(.black)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 7).fill(Self.brandLime)
)
}
.buttonStyle(.plain)
.accessibilityLabel(L10n.notchCustomizeButton)
.accessibilityHint("Opens live edit mode for resizing and positioning the notch directly.")
}
// MARK: - Shared row chrome
/// A row with an icon + label on the left and trailing content on
/// the right. Visual constants match `SystemSettingsView.TabToggle`
/// so themePicker / fontSize / hardwareMode rows share the exact
/// look of the surrounding tabs.
private func controlRow<Trailing: View>(
icon: String,
label: String,
@ViewBuilder trailing: () -> Trailing
) -> some View {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.5))
.frame(width: 16)
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.7))
Spacer(minLength: 0)
trailing()
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 7).fill(Color.white.opacity(0.03))
)
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5)
)
}
}
// MARK: - Theme preview card
/// One cell in the theme grid. Shows a miniature pill rendered in the
/// target theme's palette: accent dot + status dot + "" text + "×1"
/// badge, matching the real notch's idle-state layout so the swatch
/// communicates how the theme reads in situ. Selected cards glow in
/// their own accent color (each theme announces itself).
private struct ThemePreviewCard: View {
let themeID: NotchThemeID
let isSelected: Bool
let onTap: () -> Void
@State private var isHovered = false
private var palette: NotchPalette { NotchPalette.for(themeID) }
private var idleLabel: String {
// Theme-flavored labels, mirroring the state text from the Claude
// Design reference (themes.jsx per-state `label`). Feels alive
// rather than every card just saying "".
switch themeID {
case .classic: return L10n.isChinese ? "空闲" : "Idle"
case .forest: return L10n.isChinese ? "空闲" : "Idle"
case .neonTokyo: return "IDLE_"
case .sunset: return L10n.isChinese ? "静候" : "At rest"
case .retroArcade: return "IDLE"
case .highContrast: return L10n.isChinese ? "空闲" : "Idle"
case .sakura: return L10n.isChinese ? "小憩" : "Resting"
}
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 10) {
// Mini pill preview rounded capsule with the theme's bg,
// accent dot, a secondary status dot, status label, and
// "×1" badge. Same layout as the real notch's left wing
// at closed-state idle.
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(palette.bg)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
palette.fg.opacity(0.08),
lineWidth: 0.5
)
)
HStack(spacing: 6) {
Circle()
.fill(palette.accent)
.frame(width: 6, height: 6)
Circle()
.fill(palette.fg.opacity(0.82))
.frame(width: 8, height: 8)
Text(idleLabel)
.font(.system(
size: themeID == .retroArcade ? 9 : 10,
weight: themeID == .retroArcade ? .bold : .medium,
design: .monospaced
))
.foregroundColor(palette.fg)
.lineLimit(1)
.textCase(themeID == .retroArcade ? .uppercase : nil)
Spacer(minLength: 4)
Text("×1")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(palette.secondaryFg)
}
.padding(.horizontal, 10)
}
.frame(height: 28)
Text(L10n.notchThemeName(themeID))
.font(.system(size: 11, weight: isSelected ? .semibold : .medium))
.foregroundColor(.white.opacity(isSelected ? 0.95 : 0.72))
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected
? palette.accent.opacity(0.10)
: (isHovered
? Color.white.opacity(0.05)
: Color.white.opacity(0.02)))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(
isSelected ? palette.accent : Color.white.opacity(0.08),
lineWidth: isSelected ? 1.5 : 0.5
)
)
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.accessibilityLabel("\(L10n.notchThemeName(themeID)) theme")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}