diff --git a/Sources/Deckle/AppState.swift b/Sources/Deckle/AppState.swift index 2a076ce..f5c0666 100644 --- a/Sources/Deckle/AppState.swift +++ b/Sources/Deckle/AppState.swift @@ -45,6 +45,67 @@ final class AppState: ObservableObject { didSet { defaults.set(grainStrength, forKey: Keys.grainStrength) } } + // MARK: Favorites & Desk Lamp + + /// Pinned favorite texture IDs for quick-access switching bar. + @Published var favoriteTextureIDs: [String] { + didSet { defaults.set(favoriteTextureIDs, forKey: Keys.favoriteTextureIDs) } + } + + /// Whether Desk Lamp mode (warm ambient light pool over texture) is enabled. + @Published var enableDeskLamp: Bool { + didSet { defaults.set(enableDeskLamp, forKey: Keys.deskLamp) } + } + + /// Color temperature/warmth of Desk Lamp (0.0 = cool/neutral, 1.0 = deep amber). + @Published var deskLampWarmth: Double { + didSet { defaults.set(deskLampWarmth, forKey: Keys.deskLampWarmth) } + } + + /// Brightness/intensity of Desk Lamp pool (0.05 ... 0.50). + @Published var deskLampBrightness: Double { + didSet { defaults.set(deskLampBrightness, forKey: Keys.deskLampBrightness) } + } + + /// Spread/radius of Desk Lamp light pool (0.3 ... 1.2). + @Published var deskLampSpread: Double { + didSet { defaults.set(deskLampSpread, forKey: Keys.deskLampSpread) } + } + + enum LampPosition: String, CaseIterable, Identifiable { + case topRight = "Top Right" + case topLeft = "Top Left" + case topCenter = "Top Center" + case center = "Center" + + var id: String { rawValue } + + var point: CGPoint { + switch self { + case .topRight: return CGPoint(x: 0.85, y: 0.15) + case .topLeft: return CGPoint(x: 0.15, y: 0.15) + case .topCenter: return CGPoint(x: 0.50, y: 0.10) + case .center: return CGPoint(x: 0.50, y: 0.50) + } + } + } + + @Published var deskLampPosition: LampPosition { + didSet { defaults.set(deskLampPosition.rawValue, forKey: Keys.deskLampPosition) } + } + + func isFavorite(_ id: String) -> Bool { + favoriteTextureIDs.contains(id) + } + + func toggleFavorite(_ id: String) { + if isFavorite(id) { + favoriteTextureIDs.removeAll { $0 == id } + } else if favoriteTextureIDs.count < 6 { + favoriteTextureIDs.append(id) + } + } + var grainAdjustments: TextureRenderer.GrainAdjustments { .init(scale: grainScale, strength: grainStrength) } @@ -138,6 +199,12 @@ final class AppState: ObservableObject { static let appRuleMode = "appRuleMode" static let ruleApps = "ruleApps" static let customPapers = "customPapers" + static let favoriteTextureIDs = "favoriteTextureIDs" + static let deskLamp = "enableDeskLamp" + static let deskLampWarmth = "deskLampWarmth" + static let deskLampBrightness = "deskLampBrightness" + static let deskLampSpread = "deskLampSpread" + static let deskLampPosition = "deskLampPosition" } private let defaults = UserDefaults.standard @@ -156,6 +223,13 @@ final class AppState: ObservableObject { .flatMap { try? JSONDecoder().decode([RuleApp].self, from: $0) } ?? [] customPapers = defaults.data(forKey: Keys.customPapers) .flatMap { try? JSONDecoder().decode([CustomPaper].self, from: $0) } ?? [] + favoriteTextureIDs = defaults.stringArray(forKey: Keys.favoriteTextureIDs) + ?? ["classic-matte", "sunbaked-parchment", "whisper-weave", "carbon-ledger"] + enableDeskLamp = defaults.bool(forKey: Keys.deskLamp) + deskLampWarmth = defaults.object(forKey: Keys.deskLampWarmth) as? Double ?? 0.6 + deskLampBrightness = defaults.object(forKey: Keys.deskLampBrightness) as? Double ?? 0.25 + deskLampSpread = defaults.object(forKey: Keys.deskLampSpread) as? Double ?? 0.75 + deskLampPosition = LampPosition(rawValue: defaults.string(forKey: Keys.deskLampPosition) ?? "") ?? .topRight } private func scheduleSnoozeExpiry() { diff --git a/Sources/Deckle/MenuView.swift b/Sources/Deckle/MenuView.swift index 27ac2ab..5cac3da 100644 --- a/Sources/Deckle/MenuView.swift +++ b/Sources/Deckle/MenuView.swift @@ -17,8 +17,12 @@ struct MenuView: View { header intensitySection Divider() + pinnedFavoritesSection + Divider() textureSections Divider() + deskLampSection + Divider() grainSection Divider() snoozeSection @@ -43,9 +47,29 @@ struct MenuView: View { private var header: some View { HStack { - VStack(alignment: .leading, spacing: 1) { - Text("Deckle") - .font(.headline) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("Deckle") + .font(.headline) + if state.enableDeskLamp && state.isEnabled && !state.isSnoozed { + Label("Lamp", systemImage: "lamp.desk.fill") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.amberAccent.opacity(0.2)) + .foregroundStyle(Color.amberAccent) + .clipShape(Capsule()) + } + if state.isSnoozed { + Label("Snoozed", systemImage: "moon.fill") + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.orange.opacity(0.2)) + .foregroundStyle(.orange) + .clipShape(Capsule()) + } + } Text(statusText) .font(.caption) .foregroundStyle(.secondary) @@ -61,7 +85,7 @@ struct MenuView: View { private var intensitySection: some View { VStack(alignment: .leading, spacing: 4) { HStack { - Text("Intensity") + Label("Intensity", systemImage: "slider.horizontal.3") .font(.subheadline) Spacer() Text("\(Int(state.intensity * 100))%") @@ -74,22 +98,63 @@ struct MenuView: View { .disabled(!state.isEnabled) } + private var pinnedFavoritesSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label("Pinned", systemImage: "pin.fill") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Text("\(state.favoriteTextureIDs.count)/6") + .font(.caption2) + .foregroundStyle(.tertiary) + } + let favorites = state.favoriteTextureIDs.compactMap { id -> TexturePreset? in + if let custom = state.customPapers.first(where: { $0.id == id }) { + return TexturePreset(custom: custom) + } + return TexturePreset.all.first(where: { $0.id == id }) + } + if !favorites.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(favorites) { preset in + CompactFavoritePill( + preset: preset, + isSelected: preset.id == state.textureID + ) { + state.textureID = preset.id + } + } + } + .padding(.vertical, 2) + } + } else { + Text("Hover over any paper swatch below and click ★ to pin it here.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .disabled(!state.isEnabled) + } + private var textureSections: some View { ScrollView { VStack(alignment: .leading, spacing: 10) { - textureGroup(title: "Papers", presets: TexturePreset.light) - textureGroup(title: "Dark", presets: TexturePreset.dark) + textureGroup(title: "Papers", icon: "doc.on.doc", presets: TexturePreset.light) + textureGroup(title: "Dark", icon: "moon.fill", presets: TexturePreset.dark) myPapersGroup } .padding(.vertical, 2) } - .frame(height: 264) + .frame(height: 250) } private var myPapersGroup: some View { VStack(alignment: .leading, spacing: 6) { HStack { - Text("My Papers") + Label("My Papers", systemImage: "sparkles") .font(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) @@ -131,9 +196,9 @@ struct MenuView: View { } } - private func textureGroup(title: String, presets: [TexturePreset]) -> some View { + private func textureGroup(title: String, icon: String, presets: [TexturePreset]) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(title) + Label(title, systemImage: icon) .font(.caption) .fontWeight(.semibold) .foregroundStyle(.secondary) @@ -153,10 +218,67 @@ struct MenuView: View { } } + @State private var deskLampExpanded = false + + private var deskLampSection: some View { + DisclosureGroup(isExpanded: $deskLampExpanded) { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Warmth") + .font(.caption) + .foregroundStyle(.secondary) + Slider(value: $state.deskLampWarmth, in: 0.0...1.0) + Text("\(Int(state.deskLampWarmth * 100))%") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + + HStack { + Text("Brightness") + .font(.caption) + .foregroundStyle(.secondary) + Slider(value: $state.deskLampBrightness, in: 0.05...0.45) + Text("\(Int(state.deskLampBrightness * 100))%") + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 36, alignment: .trailing) + } + + HStack { + Text("Position") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Picker("", selection: $state.deskLampPosition) { + ForEach(AppState.LampPosition.allCases) { pos in + Text(pos.rawValue).tag(pos) + } + } + .pickerStyle(.menu) + .labelsHidden() + } + } + .padding(.top, 6) + } label: { + HStack { + Label("Desk Lamp Mode", systemImage: "lamp.desk.fill") + .font(.subheadline) + Spacer() + Toggle("", isOn: $state.enableDeskLamp) + .toggleStyle(.switch) + .labelsHidden() + } + } + .disabled(!state.isEnabled) + } + private var grainSection: some View { VStack(alignment: .leading, spacing: 6) { HStack { - Text("Grain") + Label("Grain", systemImage: "line.3.horizontal.decrease.circle") .font(.subheadline) Spacer() Picker("", selection: $state.grainScale) { @@ -188,7 +310,7 @@ struct MenuView: View { VStack(alignment: .leading, spacing: 6) { if state.isSnoozed, let until = state.snoozeUntil { HStack { - Text("Snoozed for ") + Label("Snoozed for ", systemImage: "moon.fill") .font(.subheadline) + Text(timerInterval: Date()...until, countsDown: true) .font(.subheadline) @@ -199,7 +321,7 @@ struct MenuView: View { } } else { HStack { - Text("Snooze") + Label("Snooze", systemImage: "clock") .font(.subheadline) Spacer() ForEach([15, 30, 60], id: \.self) { minutes in @@ -216,7 +338,7 @@ struct MenuView: View { private var displaysSection: some View { VStack(alignment: .leading, spacing: 6) { - Text("Displays") + Label("Displays", systemImage: "display") .font(.subheadline) ForEach(NSScreen.screens, id: \.self) { screen in if let displayID = screen.displayID { @@ -281,7 +403,7 @@ struct MenuView: View { .padding(.top, 6) } label: { HStack(spacing: 6) { - Text("App rules") + Label("App rules", systemImage: "app.badge") .font(.subheadline) if state.appRuleMode != .everywhere { Text(state.appRuleMode == .except ? "except \(state.ruleApps.count)" : "only \(state.ruleApps.count)") @@ -405,29 +527,94 @@ struct MenuView: View { } } -/// One tappable texture thumbnail in the picker grid. -private struct TextureSwatch: View { +/// Compact pill view for the Pinned Favorites toolbar. +private struct CompactFavoritePill: View { let preset: TexturePreset let isSelected: Bool let action: () -> Void var body: some View { Button(action: action) { - VStack(spacing: 4) { + HStack(spacing: 6) { Image(nsImage: TextureRenderer.preview( for: preset, - size: CGSize(width: 88, height: 44) + size: CGSize(width: 24, height: 24) )) .resizable() - .aspectRatio(2, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke( - isSelected ? Color.accentColor : Color.primary.opacity(0.15), - lineWidth: isSelected ? 2 : 1 - ) - ) + .frame(width: 18, height: 18) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.primary.opacity(0.15), lineWidth: 0.5)) + + Text(preset.name) + .font(.caption2) + .fontWeight(isSelected ? .semibold : .regular) + .foregroundStyle(isSelected ? .primary : .secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(isSelected ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05)) + .clipShape(Capsule()) + .overlay( + Capsule() + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .help(preset.subtitle) + } +} + +/// One tappable texture thumbnail in the picker grid with favorite toggle and checkmark indicator. +private struct TextureSwatch: View { + @EnvironmentObject private var state: AppState + let preset: TexturePreset + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + ZStack(alignment: .topTrailing) { + Image(nsImage: TextureRenderer.preview( + for: preset, + size: CGSize(width: 88, height: 44) + )) + .resizable() + .aspectRatio(2, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke( + isSelected ? Color.accentColor : Color.primary.opacity(0.15), + lineWidth: isSelected ? 2 : 1 + ) + ) + + // Active checkmark badge + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(Color.accentColor, Color.white) + .padding(3) + .alignmentGuide(.top) { d in d[.top] } + } + + // Star favorite button + Button { + state.toggleFavorite(preset.id) + } label: { + Image(systemName: state.isFavorite(preset.id) ? "star.fill" : "star") + .font(.system(size: 10)) + .foregroundStyle(state.isFavorite(preset.id) ? Color.orange : Color.white.opacity(0.8)) + .padding(3) + .background(Color.black.opacity(0.3)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(3) + .help(state.isFavorite(preset.id) ? "Unpin from favorites" : "Pin to favorites") + } + Text(preset.name) .font(.caption2) .lineLimit(1) @@ -439,3 +626,7 @@ private struct TextureSwatch: View { .help(preset.subtitle) } } + +extension Color { + static let amberAccent = Color(red: 0.92, green: 0.62, blue: 0.25) +} diff --git a/Sources/Deckle/OverlayController.swift b/Sources/Deckle/OverlayController.swift index f2c23f7..cafb501 100644 --- a/Sources/Deckle/OverlayController.swift +++ b/Sources/Deckle/OverlayController.swift @@ -89,7 +89,15 @@ final class OverlayController { }() window.setFrame(screen.frame, display: true) - window.apply(texture: state.texture, adjustments: state.grainAdjustments) + window.apply( + texture: state.texture, + adjustments: state.grainAdjustments, + deskLampEnabled: state.enableDeskLamp, + lampWarmth: state.deskLampWarmth, + lampBrightness: state.deskLampBrightness, + lampSpread: state.deskLampSpread, + lampPosition: state.deskLampPosition + ) if visible { if window.isVisible { diff --git a/Sources/Deckle/OverlayWindow.swift b/Sources/Deckle/OverlayWindow.swift index bbe8da0..394ee5e 100644 --- a/Sources/Deckle/OverlayWindow.swift +++ b/Sources/Deckle/OverlayWindow.swift @@ -34,8 +34,24 @@ final class OverlayWindow: NSWindow { override var canBecomeKey: Bool { false } override var canBecomeMain: Bool { false } - func apply(texture: TexturePreset, adjustments: TextureRenderer.GrainAdjustments) { - textureView.apply(texture: texture, adjustments: adjustments) + func apply( + texture: TexturePreset, + adjustments: TextureRenderer.GrainAdjustments, + deskLampEnabled: Bool = AppState.shared.enableDeskLamp, + lampWarmth: Double = AppState.shared.deskLampWarmth, + lampBrightness: Double = AppState.shared.deskLampBrightness, + lampSpread: Double = AppState.shared.deskLampSpread, + lampPosition: AppState.LampPosition = AppState.shared.deskLampPosition + ) { + textureView.apply( + texture: texture, + adjustments: adjustments, + deskLampEnabled: deskLampEnabled, + lampWarmth: lampWarmth, + lampBrightness: lampBrightness, + spread: lampSpread, + lampPosition: lampPosition + ) } } @@ -45,8 +61,11 @@ final class OverlayWindow: NSWindow { /// tiled by the CoreAnimation render server from the single 256×256 tile, /// so the overlay costs kilobytes of process memory regardless of screen size. final class TextureView: NSView { + override var isFlipped: Bool { true } + private var texture: TexturePreset? private var adjustments: TextureRenderer.GrainAdjustments = .none + private var lampLayer: CAGradientLayer? override init(frame: NSRect) { super.init(frame: frame) @@ -56,11 +75,79 @@ final class TextureView: NSView { required init?(coder: NSCoder) { fatalError() } - func apply(texture: TexturePreset, adjustments: TextureRenderer.GrainAdjustments) { - guard texture != self.texture || adjustments != self.adjustments else { return } - self.texture = texture - self.adjustments = adjustments - let tile = TextureRenderer.compositeTile(for: texture, adjustments: adjustments) - layer?.backgroundColor = NSColor(patternImage: tile).cgColor + override func layout() { + super.layout() + lampLayer?.frame = bounds + } + + func apply( + texture: TexturePreset, + adjustments: TextureRenderer.GrainAdjustments, + deskLampEnabled: Bool = AppState.shared.enableDeskLamp, + lampWarmth: Double = AppState.shared.deskLampWarmth, + lampBrightness: Double = AppState.shared.deskLampBrightness, + lampSpread: Double = AppState.shared.deskLampSpread, + lampPosition: AppState.LampPosition = AppState.shared.deskLampPosition + ) { + if texture != self.texture || adjustments != self.adjustments { + self.texture = texture + self.adjustments = adjustments + let tile = TextureRenderer.compositeTile(for: texture, adjustments: adjustments) + layer?.backgroundColor = NSColor(patternImage: tile).cgColor + } + + updateDeskLampLayer( + enabled: deskLampEnabled, + warmth: lampWarmth, + brightness: lampBrightness, + spread: lampSpread, + position: lampPosition + ) + } + + private func updateDeskLampLayer( + enabled: Bool, + warmth: Double, + brightness: Double, + spread: Double, + position: AppState.LampPosition + ) { + guard enabled else { + lampLayer?.removeFromSuperlayer() + lampLayer = nil + return + } + + let lamp = lampLayer ?? { + let l = CAGradientLayer() + l.type = .radial + l.frame = bounds + layer?.addSublayer(l) + lampLayer = l + return l + }() + + lamp.frame = bounds + let center = position.point + lamp.startPoint = center + lamp.endPoint = CGPoint( + x: center.x + CGFloat(spread), + y: center.y + CGFloat(spread) + ) + + // Interpolate warm incandescent light tint based on warmth parameter + // warmth = 0 -> cool neutral off-white (255, 250, 240) + // warmth = 1 -> deep warm amber (255, 180, 100) + let r: CGFloat = 1.0 + let g: CGFloat = 0.98 - CGFloat(warmth) * 0.28 + let b: CGFloat = 0.92 - CGFloat(warmth) * 0.52 + let alpha = CGFloat(brightness) + + let centerColor = NSColor(srgbRed: r, green: g, blue: b, alpha: alpha).cgColor + let midColor = NSColor(srgbRed: r, green: g, blue: b, alpha: alpha * 0.4).cgColor + let edgeColor = NSColor.clear.cgColor + + lamp.colors = [centerColor, midColor, edgeColor] + lamp.locations = [0.0, 0.45, 1.0] } }