Add position-based colorful Home shortcut palettes

Colorful shortcut cards now color by grid position instead of shortcut
identity, so colors stay in the same slot when shortcuts are reordered.

Adds a Palette option (Classic default, plus Sunset, Meadow, Berry,
Grape) and a Custom palette with a swatch-list editor (ColorPicker + hex
fields) and a switchable comma-separated text field. Color is resolved
per position and wraps by palette length, wired via a new
homeShortcutColorfulColor environment value and a Color(hex:) extension.
This commit is contained in:
Arkadiusz Fal
2026-05-28 23:26:05 +02:00
parent bcd4206c55
commit 486024834d
10 changed files with 596 additions and 51 deletions

View File

@@ -50,6 +50,67 @@ enum HomeShortcutCardStyle: String, CaseIterable, Sendable {
}
}
// MARK: - Home Shortcut Colorful Palette
/// Palette used by the "colorful" card style. Colors are applied by grid
/// position (wrapping by palette length), so a card's color depends on where
/// it sits, not on which shortcut it is.
enum HomeShortcutColorfulPalette: String, CaseIterable, Sendable {
/// Default: reproduces the original per-position look (SwiftUI named colors).
case classic
case sunset
case meadow
case berry
case grape
/// User-supplied hex colors (stored in settings).
case custom
var displayName: LocalizedStringKey {
switch self {
case .classic: return "home.shortcuts.palette.classic"
case .sunset: return "home.shortcuts.palette.sunset"
case .meadow: return "home.shortcuts.palette.meadow"
case .berry: return "home.shortcuts.palette.berry"
case .grape: return "home.shortcuts.palette.grape"
case .custom: return "home.shortcuts.palette.custom"
}
}
/// Built-in colors for this palette; `nil` for `.custom` (resolved from
/// the user's stored hex list instead).
var builtInColors: [Color]? {
switch self {
case .classic:
// Ordered to match `HomeShortcutItem.defaultOrder` so the classic
// palette reproduces the app's original colorful appearance.
return [.blue, .teal, .indigo, .pink, .purple, .orange, .green, .red, .cyan, .brown]
case .sunset:
return ["#FF6B6B", "#F06595", "#CC5DE8", "#845EF7", "#FFA94D"].compactMap { Color(hex: $0) }
case .meadow:
return ["#2B8A3E", "#37B24D", "#66A80F", "#099268", "#0CA678"].compactMap { Color(hex: $0) }
case .berry:
return ["#E64980", "#C2255C", "#A61E4D", "#862E9C", "#5F3DC4"].compactMap { Color(hex: $0) }
case .grape:
return ["#7048E8", "#9C36B5", "#C2255C", "#E8590C", "#F08C00"].compactMap { Color(hex: $0) }
case .custom:
return nil
}
}
/// Starter hex colors used to seed a new custom palette.
static let customStarterColors = ["#007AFF", "#30B0C7", "#5856D6", "#FF2D55", "#AF52DE"]
/// Resolves the color for a shortcut at `position` in the grid, wrapping by
/// palette length. Falls back to the Classic palette if the selection yields
/// no usable colors (e.g. an empty or all-invalid custom list).
static func color(forPosition position: Int, palette: HomeShortcutColorfulPalette, customHex: [String]) -> Color {
let colors = palette == .custom ? customHex.compactMap { Color(hex: $0) } : (palette.builtInColors ?? [])
let usable = colors.isEmpty ? (classic.builtInColors ?? [.accentColor]) : colors
guard !usable.isEmpty else { return .accentColor }
return usable[position % usable.count]
}
}
// MARK: - Home Section Layout
/// Layout mode for the configurable home sections (Continue Watching, Feed, etc.).

View File

@@ -13,6 +13,8 @@ struct HomeSettingsView: View {
// Local state for editing (copied from settings on appear, saved on dismiss)
@State private var shortcutLayout: HomeShortcutLayout = .cards
@State private var shortcutCardStyle: HomeShortcutCardStyle = .plain
@State private var shortcutColorfulPalette: HomeShortcutColorfulPalette = .classic
@State private var shortcutCustomPaletteColors: [String] = []
@State private var shortcutOrder: [HomeShortcutItem] = []
@State private var shortcutVisibility: [HomeShortcutItem: Bool] = [:]
@State private var sectionOrder: [HomeSectionItem] = []
@@ -54,7 +56,11 @@ struct HomeSettingsView: View {
#if os(macOS)
.listStyle(.inset)
.navigationDestination(isPresented: $showingStyleSettings) {
HomeShortcutStyleView(style: $shortcutCardStyle)
HomeShortcutStyleView(
style: $shortcutCardStyle,
palette: $shortcutColorfulPalette,
customColors: $shortcutCustomPaletteColors
)
}
#endif
#if !os(tvOS)
@@ -111,7 +117,11 @@ struct HomeSettingsView: View {
systemImage: "paintpalette",
trailing: { Text(shortcutCardStyle.displayName) }
) {
HomeShortcutStyleView(style: $shortcutCardStyle)
HomeShortcutStyleView(
style: $shortcutCardStyle,
palette: $shortcutColorfulPalette,
customColors: $shortcutCustomPaletteColors
)
}
#endif
}
@@ -311,6 +321,8 @@ struct HomeSettingsView: View {
shortcutLayout = settings.homeShortcutLayout
shortcutCardStyle = settings.homeShortcutCardStyle
shortcutColorfulPalette = settings.homeShortcutColorfulPalette
shortcutCustomPaletteColors = settings.homeShortcutCustomPaletteColors
shortcutOrder = settings.homeShortcutOrder
shortcutVisibility = settings.homeShortcutVisibility
sectionOrder = settings.homeSectionOrder
@@ -332,6 +344,8 @@ struct HomeSettingsView: View {
guard let settings = settingsManager else { return }
settings.homeShortcutLayout = shortcutLayout
settings.homeShortcutCardStyle = shortcutCardStyle
settings.homeShortcutColorfulPalette = shortcutColorfulPalette
settings.homeShortcutCustomPaletteColors = shortcutCustomPaletteColors
settings.homeShortcutOrder = shortcutOrder
settings.homeShortcutVisibility = shortcutVisibility
settings.homeSectionOrder = sectionOrder

View File

@@ -10,6 +10,8 @@ import SwiftUI
struct HomeShortcutCardView<StatusIndicator: View>: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
/// Position-resolved colorful color; when set it overrides `colorfulColor`.
@Environment(\.homeShortcutColorfulColor) private var positionColorfulColor
#if os(tvOS)
@Environment(\.isFocused) private var isFocused
#endif
@@ -67,7 +69,7 @@ struct HomeShortcutCardView<StatusIndicator: View>: View {
}
private var fillColor: Color {
cardStyle == .colorful ? colorfulColor : accentColor
cardStyle == .colorful ? (positionColorfulColor ?? colorfulColor) : accentColor
}
private var iconColor: Color {

View File

@@ -11,6 +11,23 @@ import SwiftUI
struct HomeShortcutStyleView: View {
@Binding var style: HomeShortcutCardStyle
@Binding var palette: HomeShortcutColorfulPalette
@Binding var customColors: [String]
/// Editing mode for the custom palette.
private enum CustomEditMode: String, CaseIterable {
case list
case text
var displayName: LocalizedStringKey {
switch self {
case .list: return "home.shortcuts.palette.custom.mode.list"
case .text: return "home.shortcuts.palette.custom.mode.text"
}
}
}
@State private var customEditMode: CustomEditMode = .list
private let previewColumns = [GridItem(.adaptive(minimum: 150), spacing: 16)]
@@ -31,9 +48,17 @@ struct HomeShortcutStyleView: View {
#endif
}
if style == .colorful {
paletteSection
if palette == .custom {
customColorsSection
}
}
Section {
LazyVGrid(columns: previewColumns, spacing: 16) {
ForEach(previewItems) { item in
ForEach(Array(previewItems.enumerated()), id: \.element.id) { index, item in
HomeShortcutCardView(
icon: item.icon,
title: item.localizedTitle,
@@ -43,6 +68,10 @@ struct HomeShortcutStyleView: View {
colorfulColor: item.cardColor,
styleOverride: style
)
.environment(
\.homeShortcutColorfulColor,
HomeShortcutColorfulPalette.color(forPosition: index, palette: palette, customHex: customColors)
)
}
}
.padding(.vertical, 4)
@@ -60,6 +89,161 @@ struct HomeShortcutStyleView: View {
#endif
}
// MARK: - Palette
private var paletteSection: some View {
Section {
Picker(String(localized: "home.settings.shortcuts.palette"), selection: $palette) {
ForEach(HomeShortcutColorfulPalette.allCases, id: \.self) { palette in
Text(palette.displayName).tag(palette)
}
}
// Swatch strip for the selected palette.
swatchStrip(for: paletteSwatchColors)
.padding(.vertical, 4)
} header: {
Text(String(localized: "home.settings.shortcuts.palette"))
}
}
/// Colors shown in the selected-palette swatch strip.
private var paletteSwatchColors: [Color] {
if palette == .custom {
let parsed = customColors.compactMap { Color(hex: $0) }
return parsed.isEmpty ? (HomeShortcutColorfulPalette.classic.builtInColors ?? []) : parsed
}
return palette.builtInColors ?? []
}
private func swatchStrip(for colors: [Color]) -> some View {
HStack(spacing: 6) {
ForEach(Array(colors.enumerated()), id: \.offset) { _, color in
RoundedRectangle(cornerRadius: 5)
.fill(color)
.frame(height: 22)
}
}
}
// MARK: - Custom Colors
private var customColorsSection: some View {
Section {
Picker(String(localized: "home.shortcuts.palette.custom.mode"), selection: $customEditMode) {
ForEach(CustomEditMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
switch customEditMode {
case .list:
ForEach(customColors.indices, id: \.self) { index in
customColorRow(index: index)
}
.onDelete { offsets in
customColors.remove(atOffsets: offsets)
}
Button {
let seed = HomeShortcutColorfulPalette.customStarterColors.first ?? "#4D96FF"
customColors.append(seed)
} label: {
Label(String(localized: "home.shortcuts.palette.custom.add"), systemImage: "plus.circle.fill")
}
case .text:
TextField(
String(localized: "home.shortcuts.palette.custom.placeholder"),
text: customColorsText,
axis: .vertical
)
.lineLimit(3 ... 6)
#if os(iOS)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
#endif
.font(.body.monospaced())
}
} header: {
Text(String(localized: "home.shortcuts.palette.custom.header"))
} footer: {
Text(String(localized: "home.shortcuts.palette.custom.footer"))
}
}
private func customColorRow(index: Int) -> some View {
HStack(spacing: 12) {
ColorPicker("", selection: colorBinding(at: index), supportsOpacity: false)
.labelsHidden()
TextField("#RRGGBB", text: hexBinding(at: index))
#if os(iOS)
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
#endif
.font(.body.monospaced())
Spacer(minLength: 0)
Button(role: .destructive) {
guard customColors.indices.contains(index) else { return }
customColors.remove(at: index)
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
// MARK: - Bindings
/// Two-way binding between a stored hex string and a SwiftUI Color for the
/// native ColorPicker.
private func colorBinding(at index: Int) -> Binding<Color> {
Binding(
get: {
guard customColors.indices.contains(index) else { return .gray }
return Color(hex: customColors[index]) ?? .gray
},
set: { newColor in
guard customColors.indices.contains(index) else { return }
customColors[index] = newColor.toHexString()
}
)
}
/// Editable hex text for a single custom color.
private func hexBinding(at index: Int) -> Binding<String> {
Binding(
get: {
guard customColors.indices.contains(index) else { return "" }
return customColors[index]
},
set: { newValue in
guard customColors.indices.contains(index) else { return }
customColors[index] = newValue
}
)
}
/// Comma/newline separated hex list for the text-editing mode.
private var customColorsText: Binding<String> {
Binding(
get: { customColors.joined(separator: ", ") },
set: { newValue in
customColors = newValue
.split(whereSeparator: { $0 == "," || $0 == "\n" })
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
)
}
// MARK: - Preview Helpers
/// Shortcuts without a meaningful count hide it in the filled layout,
/// mirroring `HomeView`'s real card configuration.
private func showsCount(for item: HomeShortcutItem) -> Bool {
@@ -89,8 +273,12 @@ struct HomeShortcutStyleView: View {
#Preview {
NavigationStack {
HomeShortcutStyleView(style: .constant(.colorful))
.appEnvironment(.preview)
HomeShortcutStyleView(
style: .constant(.colorful),
palette: .constant(.classic),
customColors: .constant(HomeShortcutColorfulPalette.customStarterColors)
)
.appEnvironment(.preview)
}
}
#endif

View File

@@ -379,9 +379,12 @@ struct HomeView: View {
let gridSpacing: CGFloat = 16
#endif
let shortcuts = settingsManager?.visibleShortcuts() ?? HomeShortcutItem.defaultOrder
return LazyVGrid(columns: columns, spacing: gridSpacing) {
ForEach(settingsManager?.visibleShortcuts() ?? HomeShortcutItem.defaultOrder) { shortcut in
ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in
shortcutCardView(for: shortcut)
.environment(\.homeShortcutColorfulColor, colorfulColor(atPosition: index))
#if !os(tvOS)
.contextMenu { editShortcutsButton }
#endif
@@ -396,6 +399,14 @@ struct HomeView: View {
#endif
}
/// Resolves the colorful-style color for a shortcut at the given grid
/// position from the selected palette (and custom colors when applicable).
private func colorfulColor(atPosition position: Int) -> Color {
let palette = settingsManager?.homeShortcutColorfulPalette ?? .classic
let customHex = settingsManager?.homeShortcutCustomPaletteColors ?? []
return HomeShortcutColorfulPalette.color(forPosition: position, palette: palette, customHex: customHex)
}
private var shortcutsList: some View {
let shortcuts = settingsManager?.visibleShortcuts() ?? HomeShortcutItem.defaultOrder
return ForEach(Array(shortcuts.enumerated()), id: \.element) { index, shortcut in