diff --git a/README.md b/README.md index cdf824f..39db78f 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,14 @@ scripts/ Build & release scripts Extensions are JavaScript packages that run inside a sandboxed JavaScriptCore context. Read the full guide at [dynamicisland.app/docs](https://dynamicisland.app/docs) or in [EXTENSIONS.md](EXTENSIONS.md). +## Appearance + +Home slots, compact island size, animation intensity, and reduced motion can be configured in Settings. See [docs/APPEARANCE.md](docs/APPEARANCE.md). + ## Calendar The Calendar module supports account/source selection, holiday and birthday filters, duplicate collapse, and meeting-link actions. See [docs/CALENDAR.md](docs/CALENDAR.md). + ## File Shelf The built-in Shelf module can stage local files, folders, URLs, text snippets, and images from the island. See [docs/SHELF.md](docs/SHELF.md). diff --git a/SuperIsland/App/AppState.swift b/SuperIsland/App/AppState.swift index 4a986dc..566dfe5 100644 --- a/SuperIsland/App/AppState.swift +++ b/SuperIsland/App/AppState.swift @@ -263,9 +263,15 @@ final class AppState: ObservableObject { // Appearance settings @AppStorage("appearance.animationSpeed") var animationSpeed: Double = 1.0 @AppStorage("appearance.bounceAmount") var bounceAmount: Double = 0.25 + @AppStorage("appearance.animationLevel") var animationLevelRaw = AnimationLevel.full.rawValue + @AppStorage("appearance.reduceMotion") var reduceMotion = false @AppStorage("appearance.compactIslandWidth") var compactIslandWidth: Double = 200 @AppStorage("appearance.compactIslandHeight") var compactIslandHeight: Double = 36 + @AppStorage("home.leadingPanel") var homeLeadingPanelRaw = HomePanel.nowPlaying.rawValue + @AppStorage("home.centerPanel") var homeCenterPanelRaw = HomePanel.calendar.rawValue + @AppStorage("home.trailingPanel") var homeTrailingPanelRaw = HomePanel.weather.rawValue + // General settings @AppStorage("general.showMenuBarIcon") var showMenuBarIcon = true @AppStorage("general.showOnAllSpaces") var showOnAllSpaces = true @@ -300,13 +306,57 @@ final class AppState: ObservableObject { /// Recomputed per-call so the live bounce-amount setting takes effect /// without needing an app relaunch. var notchAnimation: Animation { - .interactiveSpring( - duration: 0.5, - extraBounce: bounceAmount, + if shouldReduceMotion { + return .easeOut(duration: 0.14) + } + + let level = animationLevel + let duration = 0.5 / max(0.5, animationSpeed) + let bounce = min(bounceAmount, level.bounceLimit) + return .interactiveSpring( + duration: duration, + extraBounce: bounce, blendDuration: 0.125 ) } + var contentSwapAnimation: Animation { + shouldReduceMotion + ? .easeOut(duration: 0.12) + : .smooth(duration: 0.22 / max(0.5, animationSpeed)) + } + + var hoverAnimation: Animation { + shouldReduceMotion + ? .easeOut(duration: 0.08) + : .easeOut(duration: 0.18 / max(0.5, animationSpeed)) + } + + var shouldReduceMotion: Bool { + reduceMotion || animationLevel == .reduced + } + + var animationLevel: AnimationLevel { + get { AnimationLevel(rawValue: animationLevelRaw) ?? .full } + set { animationLevelRaw = newValue.rawValue } + } + + var homePanels: [HomePanel] { + let configured = [ + HomePanel(rawValue: homeLeadingPanelRaw) ?? .nowPlaying, + HomePanel(rawValue: homeCenterPanelRaw) ?? .calendar, + HomePanel(rawValue: homeTrailingPanelRaw) ?? .weather + ] + + var seen = Set() + return configured.filter { panel in + guard panel != .none else { return false } + guard seen.insert(panel).inserted else { return false } + guard let module = panel.module else { return false } + return isModuleEnabled(module) + } + } + // MARK: - State Transitions func toggleExpansion() { @@ -675,7 +725,7 @@ final class AppState: ObservableObject { nextModule = modules[modules.count - 1] } - withAnimation(Constants.contentSwap) { + withAnimation(contentSwapAnimation) { previousModule = activeModule activeModule = nextModule } @@ -689,14 +739,14 @@ final class AppState: ObservableObject { if case .builtIn(let builtIn) = module, !isModuleEnabled(builtIn) { return } - withAnimation(Constants.contentSwap) { + withAnimation(contentSwapAnimation) { previousModule = activeModule activeModule = module } } func selectFullExpandedTab(_ tab: FullExpandedTab) { - withAnimation(Constants.contentSwap) { + withAnimation(contentSwapAnimation) { fullExpandedSelectedTab = tab } @@ -708,7 +758,7 @@ final class AppState: ObservableObject { } func showHomeTab() { - withAnimation(Constants.contentSwap) { + withAnimation(contentSwapAnimation) { fullExpandedSelectedTab = .home } shelfDefaultToShelf = false @@ -1197,7 +1247,7 @@ final class AppState: ObservableObject { : (currentIndex - 1 + tabs.count) % tabs.count let nextTab = tabs[nextIndex] - withAnimation(Constants.contentSwap) { + withAnimation(contentSwapAnimation) { fullExpandedSelectedTab = nextTab if case .module(let module) = nextTab { previousModule = activeModule @@ -1252,7 +1302,7 @@ final class AppState: ObservableObject { activeModule = .builtIn(.shelf) fullExpandedSelectedTab = .module(.builtIn(.shelf)) - withAnimation(currentState == .compact ? notchAnimation : Constants.contentSwap) { + withAnimation(currentState == .compact ? notchAnimation : contentSwapAnimation) { currentState = .fullExpanded } } @@ -1269,7 +1319,7 @@ final class AppState: ObservableObject { activeModule = .builtIn(.shelf) fullExpandedSelectedTab = .module(.builtIn(.shelf)) - withAnimation(currentState == .compact ? notchAnimation : Constants.contentSwap) { + withAnimation(currentState == .compact ? notchAnimation : contentSwapAnimation) { currentState = .fullExpanded } } diff --git a/SuperIsland/Home/HomeLayoutPreferences.swift b/SuperIsland/Home/HomeLayoutPreferences.swift new file mode 100644 index 0000000..66530ef --- /dev/null +++ b/SuperIsland/Home/HomeLayoutPreferences.swift @@ -0,0 +1,61 @@ +import SwiftUI + +enum HomePanel: String, CaseIterable, Identifiable { + case none + case nowPlaying + case calendar + case weather + + var id: String { rawValue } + + var title: String { + switch self { + case .none: return "Empty" + case .nowPlaying: return "Now Playing" + case .calendar: return "Calendar" + case .weather: return "Weather" + } + } + + var iconName: String { + switch self { + case .none: return "minus.circle" + case .nowPlaying: return "music.note" + case .calendar: return "calendar" + case .weather: return "cloud.sun.fill" + } + } + + var module: ModuleType? { + switch self { + case .none: return nil + case .nowPlaying: return .nowPlaying + case .calendar: return .calendar + case .weather: return .weather + } + } +} + +enum AnimationLevel: String, CaseIterable, Identifiable { + case full + case subtle + case reduced + + var id: String { rawValue } + + var title: String { + switch self { + case .full: return "Full" + case .subtle: return "Subtle" + case .reduced: return "Reduced" + } + } + + var bounceLimit: Double { + switch self { + case .full: return 0.5 + case .subtle: return 0.08 + case .reduced: return 0 + } + } +} diff --git a/SuperIsland/Settings/AppearanceSettingsView.swift b/SuperIsland/Settings/AppearanceSettingsView.swift index 66fa35a..b3b301b 100644 --- a/SuperIsland/Settings/AppearanceSettingsView.swift +++ b/SuperIsland/Settings/AppearanceSettingsView.swift @@ -7,6 +7,8 @@ struct AppearanceSettingsView: View { // and for the @AppStorage initial values in AppState. private enum Defaults { static let bounceAmount: Double = 0.25 + static let animationLevel = AnimationLevel.full + static let reduceMotion = false static let compactIslandWidth: Double = 200 static let compactIslandHeight: Double = 36 } @@ -17,9 +19,37 @@ struct AppearanceSettingsView: View { section( title: "Animation", reset: { + appState.animationLevel = Defaults.animationLevel + appState.reduceMotion = Defaults.reduceMotion appState.bounceAmount = Defaults.bounceAmount } ) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Animation intensity").font(.system(size: 13)) + Text("Controls island motion and transition strength") + .font(.system(size: 11)).foregroundColor(.secondary) + } + Spacer(minLength: 12) + Picker("", selection: $appState.animationLevelRaw) { + ForEach(AnimationLevel.allCases) { level in + Text(level.title).tag(level.rawValue) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 120) + } + .padding(.horizontal, 16).padding(.vertical, 12) + + SettingRowDivider() + SettingToggleRow( + title: "Reduce motion", + description: "Simplify island transitions and content swaps", + isOn: $appState.reduceMotion + ) + + SettingRowDivider() HStack { VStack(alignment: .leading, spacing: 2) { Text("Bounce").font(.system(size: 13)) diff --git a/SuperIsland/Settings/ModuleSettingsView.swift b/SuperIsland/Settings/ModuleSettingsView.swift index 79b7f50..327c293 100644 --- a/SuperIsland/Settings/ModuleSettingsView.swift +++ b/SuperIsland/Settings/ModuleSettingsView.swift @@ -17,6 +17,15 @@ struct ModuleSettingsView: View { SettingToggleRow(title: "Volume HUD", isOn: $appState.volumeHUDEnabled) } + SettingSectionLabel(title: "Home") + SettingGroup { + homeSlotRow(title: "Left slot", selection: $appState.homeLeadingPanelRaw) + SettingRowDivider() + homeSlotRow(title: "Center slot", selection: $appState.homeCenterPanelRaw) + SettingRowDivider() + homeSlotRow(title: "Right slot", selection: $appState.homeTrailingPanelRaw) + } + SettingSectionLabel(title: "System") SettingGroup { SettingToggleRow(title: "Battery", isOn: $appState.batteryEnabled) @@ -297,4 +306,23 @@ struct ModuleSettingsView: View { .padding(.horizontal, 16) .padding(.vertical, 11) } + + private func homeSlotRow(title: String, selection: Binding) -> some View { + HStack { + Text(title) + .font(.system(size: 13)) + Spacer(minLength: 12) + Picker("", selection: selection) { + ForEach(HomePanel.allCases) { panel in + Label(panel.title, systemImage: panel.iconName) + .tag(panel.rawValue) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 150) + } + .padding(.horizontal, 16) + .padding(.vertical, 11) + } } diff --git a/SuperIsland/Views/HomeScreenView.swift b/SuperIsland/Views/HomeScreenView.swift index e093dca..bbda5f1 100644 --- a/SuperIsland/Views/HomeScreenView.swift +++ b/SuperIsland/Views/HomeScreenView.swift @@ -26,27 +26,20 @@ struct HomeScreenView: View { } private var visiblePanels: [HomePanel] { - var result: [HomePanel] = [] - if appState.nowPlayingEnabled { result.append(.nowPlaying) } - if appState.calendarEnabled { result.append(.calendar) } - if appState.weatherEnabled { result.append(.weather) } - return result + appState.homePanels } private var threePanelLayout: some View { HStack(alignment: .top, spacing: 14) { - HomeNowPlayingPanel() - .frame(width: 228, alignment: .topLeading) - - homeDivider - - HomeCalendarPanel() - .frame(maxWidth: .infinity, alignment: .topLeading) - - homeDivider + ForEach(Array(visiblePanels.enumerated()), id: \.element.id) { index, panel in + panelView(for: panel) + .frame(width: preferredWidth(for: panel), alignment: .topLeading) + .frame(maxWidth: panel == .calendar ? .infinity : nil, alignment: .topLeading) - HomeWeatherPanel() - .frame(width: 150, alignment: .topLeading) + if index < visiblePanels.count - 1 { + homeDivider + } + } } } @@ -66,6 +59,8 @@ struct HomeScreenView: View { @ViewBuilder private func panelView(for panel: HomePanel) -> some View { switch panel { + case .none: + EmptyView() case .nowPlaying: HomeNowPlayingPanel() case .calendar: @@ -81,14 +76,17 @@ struct HomeScreenView: View { .frame(width: 1) .padding(.vertical, 4) } -} - -private enum HomePanel: String, Identifiable { - case nowPlaying - case calendar - case weather - var id: String { rawValue } + private func preferredWidth(for panel: HomePanel) -> CGFloat? { + switch panel { + case .nowPlaying: + return visiblePanels.count == 3 ? 228 : nil + case .weather: + return visiblePanels.count == 3 ? 150 : nil + case .calendar, .none: + return nil + } + } } private struct HomeNowPlayingPanel: View { diff --git a/SuperIsland/Views/IslandContainerView.swift b/SuperIsland/Views/IslandContainerView.swift index eec1bdb..e0f4264 100644 --- a/SuperIsland/Views/IslandContainerView.swift +++ b/SuperIsland/Views/IslandContainerView.swift @@ -82,7 +82,7 @@ struct IslandContainerView: View { onDragEnded: { handleSwipe(value: $0) } )) .onContinuousHover(coordinateSpace: .local) { phase in - handleSurfaceHover(phase: phase, surfaceSize: surfaceSize) + handleSurfaceHover(phase: phase) } .onTapGesture { handleSurfaceTap() @@ -113,17 +113,11 @@ struct IslandContainerView: View { CompactView() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .opacity(compactContentOpacity) - .transition( - .scale(scale: 0.85, anchor: .top) - .combined(with: .opacity) - ) + .transition(contentTransition(scale: 0.85)) } else { expandedIslandLayout .opacity(compactContentOpacity) - .transition( - .scale(scale: 0.5, anchor: .top) - .combined(with: .opacity) - ) + .transition(contentTransition(scale: 0.5)) } } @@ -148,6 +142,12 @@ struct IslandContainerView: View { 1.0 } + private func contentTransition(scale: CGFloat) -> AnyTransition { + appState.shouldReduceMotion + ? .opacity + : .scale(scale: scale, anchor: .top).combined(with: .opacity) + } + // Shadows are intentionally disabled in the compact state. The island // panel is non-activating and sits in the transparent region around the // notch — any non-zero shadow pixel in that transparent region gets @@ -338,7 +338,7 @@ struct IslandContainerView: View { .frame(width: windowWidth, height: appState.currentContentFrameHeight, alignment: .center) .frame(maxHeight: .infinity, alignment: .top) .opacity(appState.isHovering ? 1 : 0.78) - .animation(.easeOut(duration: 0.18), value: appState.isHovering) + .animation(appState.hoverAnimation, value: appState.isHovering) .transition(.opacity) .allowsHitTesting(appState.currentState != .compact) } @@ -378,20 +378,10 @@ struct IslandContainerView: View { syncHoverState() } - private func handleSurfaceHover(phase: HoverPhase, surfaceSize: CGSize) { + private func handleSurfaceHover(phase: HoverPhase) { switch phase { - case .active(let location): - guard appState.currentState == .compact, - appState.shouldUseMinimalCompactLayout else { - setIslandSurfaceHover(true) - return - } - - let centerGapWidth = appState.compactMinimalCenterGapWidth - let centerMinX = max(0, (surfaceSize.width - centerGapWidth) / 2) - let centerMaxX = min(surfaceSize.width, centerMinX + centerGapWidth) - let hoveringCenter = location.x >= centerMinX && location.x <= centerMaxX - setIslandSurfaceHover(hoveringCenter) + case .active: + setIslandSurfaceHover(true) case .ended: setIslandSurfaceHover(false) } diff --git a/docs/APPEARANCE.md b/docs/APPEARANCE.md new file mode 100644 index 0000000..a1e129d --- /dev/null +++ b/docs/APPEARANCE.md @@ -0,0 +1,24 @@ +# Appearance and Home Layout + +SuperIsland lets users tune the compact island without changing module behavior. + +## Home slots + +Settings -> Modules -> Home contains three compact home slots: + +- Left slot +- Center slot +- Right slot + +Each slot can show Now Playing, Calendar, Weather, or Empty. Duplicate selections are ignored, and disabled modules stay hidden until the module is enabled again. + +## Motion + +Settings -> Appearance includes: + +- Animation intensity: Full, Subtle, or Reduced +- Reduce motion: simplifies island transitions and content swaps +- Bounce: fine-tunes spring bounce when motion is not reduced +- Compact island width and height controls + +Reduced motion also removes scale-heavy content transitions in the island surface.