diff --git a/.github/ISSUE_TEMPLATE/feedback.md b/.github/ISSUE_TEMPLATE/feedback.md index 23af692..37cd168 100644 --- a/.github/ISSUE_TEMPLATE/feedback.md +++ b/.github/ISSUE_TEMPLATE/feedback.md @@ -1,6 +1,6 @@ --- name: Feedback -about: Give feedback regarding moonleaf or a specific part of it +about: Give feedback regarding Petalia or a specific part of it title: '' labels: '' assignees: '' diff --git a/.gitignore b/.gitignore index 6a3e68d..6687f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -**/.DS_Store \ No newline at end of file +**/.DS_Store +.build-cache/ +build/ +.agents/ \ No newline at end of file diff --git a/README.md b/README.md index b0583af..f869f77 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@


- moonleaf + Petalia
- moonleaf + Petalia

@@ -19,7 +19,7 @@

-moonleaf is a feature-packed Wallpaper Manager for macOS, with support for gifs, videos, online wallpaper browsing, multi-monitor wallpapers, exporting, among other many things! +Petalia is a feature-packed Wallpaper Manager for macOS, with support for gifs, videos, online wallpaper browsing, multi-monitor wallpapers, exporting, among other many things!

@@ -45,7 +45,7 @@ brew install --cask parkuoa/moonleaf/moonleaf ``` then, ``` -xattr -dr com.apple.quarantine /Applications/moonleaf.app +xattr -dr com.apple.quarantine /Applications/Petalia.app ``` ## Manual @@ -60,13 +60,13 @@ xattr -dr com.apple.quarantine /Applications/moonleaf.app > You only need to do this once.

- +

--- ## License -moonleaf is licensed under the [MIT License](./LICENSE). \ +Petalia is licensed under the [MIT License](./LICENSE). \ Versions pre-v3.0 (macpaper) are licensed under the GNU General Public License v3.0 (GPLv3). ## ๐Ÿ”จ Building from Source @@ -86,15 +86,15 @@ It is advised that you, alongside the strings file, place a file named `credit` ### Quarantine If you suspect the app is quarantined, run the following on your Terminal after dragging the app to Applications: ```bash -xattr -l /Applications/moonleaf.app +xattr -l /Applications/Petalia.app ``` Which shall output ```com.apple.quarantine: ...;{BROWSER};``` if the app IS quarantined. In that case, run: ```bash -xattr -dr com.apple.quarantine /Applications/moonleaf.app +xattr -dr com.apple.quarantine /Applications/Petalia.app ``` -### Apple could not verify "moonleaf" is free of malware... +### Apple could not verify "Petalia" is free of malware... You can fix this by doing the same steps as [here](https://github.com/parkuoa/moonleaf/tree/main/README.md#installation). ## โค๏ธ Support me diff --git a/VariableBlur b/VariableBlur new file mode 100755 index 0000000..186cc71 Binary files /dev/null and b/VariableBlur differ diff --git a/artwork/icns/Petalia/Petalia-2.icns b/artwork/icns/Petalia/Petalia-2.icns new file mode 100644 index 0000000..d7deae6 Binary files /dev/null and b/artwork/icns/Petalia/Petalia-2.icns differ diff --git a/artwork/icns/Petalia/Petalia.icns b/artwork/icns/Petalia/Petalia.icns new file mode 100644 index 0000000..451e6c3 Binary files /dev/null and b/artwork/icns/Petalia/Petalia.icns differ diff --git a/artwork/icns/moonleaf/moonleaf.icns b/artwork/icns/moonleaf/moonleaf.icns deleted file mode 100644 index d30ff8d..0000000 Binary files a/artwork/icns/moonleaf/moonleaf.icns and /dev/null differ diff --git a/artwork/png/Petalia-raw-2.png b/artwork/png/Petalia-raw-2.png new file mode 100644 index 0000000..72d9729 Binary files /dev/null and b/artwork/png/Petalia-raw-2.png differ diff --git a/artwork/png/Petalia-raw.png b/artwork/png/Petalia-raw.png new file mode 100644 index 0000000..5956a0f Binary files /dev/null and b/artwork/png/Petalia-raw.png differ diff --git a/artwork/png/icon (2).png b/artwork/png/icon (2).png new file mode 100644 index 0000000..aeca949 Binary files /dev/null and b/artwork/png/icon (2).png differ diff --git a/artwork/png/icon.png b/artwork/png/icon.png new file mode 100644 index 0000000..b87174e Binary files /dev/null and b/artwork/png/icon.png differ diff --git a/artwork/png/moonleaf.png b/artwork/png/moonleaf.png deleted file mode 100644 index e12dccb..0000000 Binary files a/artwork/png/moonleaf.png and /dev/null differ diff --git a/artwork/png/petalia.png b/artwork/png/petalia.png new file mode 100644 index 0000000..648c59e Binary files /dev/null and b/artwork/png/petalia.png differ diff --git a/artwork/png/moonleaf2.png b/artwork/png/petalia2.png similarity index 100% rename from artwork/png/moonleaf2.png rename to artwork/png/petalia2.png diff --git a/build.md b/build.md new file mode 100644 index 0000000..e105a49 --- /dev/null +++ b/build.md @@ -0,0 +1,53 @@ +# Building Petalia from Source + +This project compiles directly from the command line using macOS developer tools (`swiftc` and `gcc`/`clang`). You do not need to open or configure Xcode to build the application. + +--- + +## ๐Ÿ“‹ Requirements +- macOS 12 (Monterey) or later +- Command Line Tools. If you don't have them installed, run: + ```bash + xcode-select --install + ``` + +--- + +## ๐Ÿ› ๏ธ Build Commands + +All builds are orchestrated via the `scripts/build.sh` script. Run them from the project's root directory: + +### 1. Incremental Build (Default) +By default, the script compiles files incrementally. Only files you have modified (and their dependents) will be recompiled, saving compilation time. +```bash +./scripts/build.sh +``` + +### 2. Clean Build +To wipe the build cache (`.build-cache/`) and compile all files from scratch: +```bash +./scripts/build.sh --clean +``` + +### 3. Build for All Architectures (Universal Binary) +To build a universal binary for both Intel (`x86_64`) and Apple Silicon (`arm64`) architectures (defaults to your host machine's architecture): +```bash +./scripts/build.sh --all +``` +*Note: You can combine this flag with clean: `./scripts/build.sh --all --clean`* + +--- + +## ๐Ÿ“ Build Output + +The successfully built application will be stored at: +``` +build/Petalia.app +``` + +--- + +## โš™๏ธ How It Works (Incremental Compilation) +- The script uses the Swift compiler's native `-incremental` mode. +- Compilation metadata and object files (`.o`) are cached under `.build-cache/`. +- Changing a single file only recompiles that specific file, bringing compile times down from minutes to a few seconds. diff --git a/glasswp/glasswp.swift b/glasswp/glasswp.swift index dd554ed..d5edce3 100644 --- a/glasswp/glasswp.swift +++ b/glasswp/glasswp.swift @@ -1,6 +1,6 @@ // // glasswp.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // @@ -380,7 +380,7 @@ final class ScreenPlayer { private func readVolume() -> Double { let volFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/volume") + .appendingPathComponent(".config/petalia/volume") if let data = try? Data(contentsOf: volFile), let str = String(data: data, encoding: .utf8), let intVal = Int(str.trimmingCharacters(in: .whitespacesAndNewlines)) { @@ -463,16 +463,16 @@ class GlasswpApp: NSObject, NSApplicationDelegate { private func registerNotifications() { DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleVolumeChange(_:)), - name: Notification.Name("com.naomisphere.moonleaf.volumeChanged"), object: nil) + name: Notification.Name("com.naomisphere.petalia.volumeChanged"), object: nil) DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleVizSettingsChange), - name: Notification.Name("com.naomisphere.moonleaf.visualizerSettingsChanged"), object: nil) + name: Notification.Name("com.naomisphere.petalia.visualizerSettingsChanged"), object: nil) DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleAutoPause(_:)), - name: Notification.Name("com.naomisphere.moonleaf.autoPauseChanged"), object: nil) + name: Notification.Name("com.naomisphere.petalia.autoPauseChanged"), object: nil) DistributedNotificationCenter.default().addObserver( self, selector: #selector(handleChangeWallpaper(_:)), - name: Notification.Name("com.naomisphere.moonleaf.changeWallpaper"), object: nil) + name: Notification.Name("com.naomisphere.petalia.changeWallpaper"), object: nil) NotificationCenter.default.addObserver( @@ -561,7 +561,7 @@ class GlasswpApp: NSObject, NSApplicationDelegate { private func loadVisualizerSettings() { let settingsFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/settings.json") + .appendingPathComponent(".config/petalia/settings.json") guard FileManager.default.fileExists(atPath: settingsFile.path), let data = try? Data(contentsOf: settingsFile), let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } @@ -646,12 +646,12 @@ final class RealVisualizerEngine { } ) - var tapRef: MTAudioProcessingTap? + var tapRef: Unmanaged? let status = MTAudioProcessingTapCreate( kCFAllocatorDefault, &callbacks, kMTAudioProcessingTapCreationFlag_PostEffects, &tapRef) - guard status == noErr, let builtTap = tapRef else { + guard status == noErr, let builtTap = tapRef?.takeRetainedValue() else { retainedSelf.release() return } diff --git a/lang/Base.lproj/Localizable.strings b/lang/Base.lproj/Localizable.strings index b672237..ca667d0 100644 --- a/lang/Base.lproj/Localizable.strings +++ b/lang/Base.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"mgr_title" = "moonleaf"; +"mgr_title" = "Petalia"; "mgr_wp_title" = "wallpapers"; "mgr_browse_title" = "browse"; "add_wallpaper" = "add wallpaper"; @@ -29,12 +29,12 @@ "sb_refresh" = "Refresh"; "sb_open_mgr" = "Open Manager"; "sb_settings" = "Settings"; -"sb_about" = "About moonleaf"; -"sb_about_text" = "Version %@ (%@)\n\nThe Wallpaper Manager for macOS"; +"sb_about" = "About Petalia"; +"sb_about_text" = "Version %@ (%@)\nThe Wallpaper Manager for macOS"; "sb_about_github" = "GitHub"; "sb_about_kofi" = "Support on Ko-fi"; "sb_perst_tooltip" = "Automatically start wallpaper on login"; -"sb_quit" = "Quit moonleaf"; +"sb_quit" = "Quit Petalia"; "mgr_settings" = "settings"; "settings" = "Settings"; @@ -95,7 +95,7 @@ "settings_general" = "General"; "settings_startup" = "Startup"; "settings_auto_start" = "Start at Login"; -"settings_auto_start_desc" = "Automatically start moonleaf when you log in"; +"settings_auto_start_desc" = "Automatically start Petalia when you log in"; "settings_updates" = "Updates"; "settings_check_updates" = "Check for Updates"; "settings_check_updates_desc" = "Check for updates on startup"; diff --git a/lang/de.lproj/Localizable.strings b/lang/de.lproj/Localizable.strings index e4aac36..a15fa8f 100644 --- a/lang/de.lproj/Localizable.strings +++ b/lang/de.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"mgr_title" = "moonleaf"; +"mgr_title" = "Petalia"; "mgr_wp_title" = "Hintergrundbilder"; "mgr_browse_title" = "Durchsuchen"; "add_wallpaper" = "Hintergrundbild hinzufรผgen"; @@ -29,12 +29,12 @@ "sb_refresh" = "Aktualisieren"; "sb_open_mgr" = "Manager รถffnen"; "sb_settings" = "Einstellungen"; -"sb_about" = "รœber moonleaf"; -"sb_about_text" = "Version %@ (%@)\n\nDer Live-Hintergrundbild-Manager fรผr macOS"; +"sb_about" = "รœber Petalia"; +"sb_about_text" = "Version %@ (%@)\nDer Live-Hintergrundbild-Manager fรผr macOS"; "sb_about_github" = "GitHub"; "sb_about_kofi" = "Auf Ko-fi unterstรผtzen"; "sb_perst_tooltip" = "Hintergrundbild automatisch beim Anmelden starten"; -"sb_quit" = "moonleaf beenden"; +"sb_quit" = "Petalia beenden"; "mgr_settings" = "Einstellungen"; "settings" = "Einstellungen"; @@ -95,7 +95,7 @@ "settings_general" = "Allgemein"; "settings_startup" = "Start"; "settings_auto_start" = "Beim Anmelden starten"; -"settings_auto_start_desc" = "moonleaf automatisch beim Anmelden starten"; +"settings_auto_start_desc" = "Petalia automatisch beim Anmelden starten"; "settings_updates" = "Updates"; "settings_check_updates" = "Nach Updates suchen"; "settings_check_updates_desc" = "Beim Start nach Updates suchen"; diff --git a/lang/en.lproj/Localizable.strings b/lang/en.lproj/Localizable.strings index b672237..ca667d0 100644 --- a/lang/en.lproj/Localizable.strings +++ b/lang/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"mgr_title" = "moonleaf"; +"mgr_title" = "Petalia"; "mgr_wp_title" = "wallpapers"; "mgr_browse_title" = "browse"; "add_wallpaper" = "add wallpaper"; @@ -29,12 +29,12 @@ "sb_refresh" = "Refresh"; "sb_open_mgr" = "Open Manager"; "sb_settings" = "Settings"; -"sb_about" = "About moonleaf"; -"sb_about_text" = "Version %@ (%@)\n\nThe Wallpaper Manager for macOS"; +"sb_about" = "About Petalia"; +"sb_about_text" = "Version %@ (%@)\nThe Wallpaper Manager for macOS"; "sb_about_github" = "GitHub"; "sb_about_kofi" = "Support on Ko-fi"; "sb_perst_tooltip" = "Automatically start wallpaper on login"; -"sb_quit" = "Quit moonleaf"; +"sb_quit" = "Quit Petalia"; "mgr_settings" = "settings"; "settings" = "Settings"; @@ -95,7 +95,7 @@ "settings_general" = "General"; "settings_startup" = "Startup"; "settings_auto_start" = "Start at Login"; -"settings_auto_start_desc" = "Automatically start moonleaf when you log in"; +"settings_auto_start_desc" = "Automatically start Petalia when you log in"; "settings_updates" = "Updates"; "settings_check_updates" = "Check for Updates"; "settings_check_updates_desc" = "Check for updates on startup"; diff --git a/lang/es.lproj/Localizable.strings b/lang/es.lproj/Localizable.strings index 6b08a99..69ff0a1 100644 --- a/lang/es.lproj/Localizable.strings +++ b/lang/es.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"mgr_title" = "moonleaf"; +"mgr_title" = "Petalia"; "mgr_wp_title" = "fondos"; "mgr_browse_title" = "explorar"; "add_wallpaper" = "aรฑadir fondo"; @@ -29,12 +29,12 @@ "sb_refresh" = "Actualizar"; "sb_open_mgr" = "Abrir app"; "sb_settings" = "Ajustes"; -"sb_about" = "Acerca de moonleaf"; -"sb_about_text" = "Versiรณn %@ (%@)\n\nEl Gestor de Fondos para macOS"; +"sb_about" = "Acerca de Petalia"; +"sb_about_text" = "Versiรณn %@ (%@)\nEl Gestor de Fondos para macOS"; "sb_about_github" = "GitHub"; "sb_about_kofi" = "Apoyar en Ko-fi"; "sb_perst_tooltip" = "Iniciar fondo automรกticamente al iniciar sesiรณn"; -"sb_quit" = "Salir de moonleaf"; +"sb_quit" = "Salir de Petalia"; "mgr_settings" = "ajustes"; "settings" = "Ajustes"; @@ -94,7 +94,7 @@ "settings_general" = "General"; "settings_startup" = "Inicio"; "settings_auto_start" = "Iniciar al iniciar sesiรณn"; -"settings_auto_start_desc" = "Iniciar moonleaf automรกticamente al iniciar sesiรณn"; +"settings_auto_start_desc" = "Iniciar Petalia automรกticamente al iniciar sesiรณn"; "settings_updates" = "Actualizaciones"; "settings_check_updates" = "Buscar actualizaciones"; "settings_check_updates_desc" = "Buscar actualizaciones al iniciar"; diff --git a/lang/pl.lproj/Localizable.strings b/lang/pl.lproj/Localizable.strings index c00d224..eb0c347 100644 --- a/lang/pl.lproj/Localizable.strings +++ b/lang/pl.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"mgr_title" = "moonleaf"; +"mgr_title" = "Petalia"; "mgr_wp_title" = "tapety"; "mgr_browse_title" = "przeglฤ…daj"; "add_wallpaper" = "dodaj tapetฤ™"; @@ -29,12 +29,12 @@ "sb_refresh" = "Odล›wieลผ"; "sb_open_mgr" = "Otwรณrz Menedลผera"; "sb_settings" = "Ustawienia"; -"sb_about" = "O moonleaf"; -"sb_about_text" = "Wersja %@ (%@)\n\nMenedลผer Tapet dla macOS"; +"sb_about" = "O Petalia"; +"sb_about_text" = "Wersja %@ (%@)\nMenedลผer Tapet dla macOS"; "sb_about_github" = "GitHub"; "sb_about_kofi" = "Wesprzyj na Ko-fi"; "sb_perst_tooltip" = "Automatycznie wล‚ฤ…cz tapetฤ™ na starcie systemu"; -"sb_quit" = "Zamknij moonleaf"; +"sb_quit" = "Zamknij Petalia"; "mgr_settings" = "ustawienia"; "settings" = "Ustawienia"; @@ -95,7 +95,7 @@ "settings_general" = "Ogรณlne"; "settings_startup" = "Uruchamianie"; "settings_auto_start" = "Uruchom od razu"; -"settings_auto_start_desc" = "Automatycznie wล‚ฤ…cz moonleaf przy starcie systemu"; +"settings_auto_start_desc" = "Automatycznie wล‚ฤ…cz Petalia przy starcie systemu"; "settings_updates" = "Aktualizowanie"; "settings_check_updates" = "Sprawdzaj Aktualizacje"; "settings_check_updates_desc" = "Sprawdzaj Aktualizacje przy uruchomieniu"; diff --git a/lang/pt-BR.lproj/Localizable.strings b/lang/pt-BR.lproj/Localizable.strings index 5c238bb..5c17122 100644 --- a/lang/pt-BR.lproj/Localizable.strings +++ b/lang/pt-BR.lproj/Localizable.strings @@ -1,4 +1,4 @@ -"mgr_title" = "moonleaf"; +"mgr_title" = "Petalia"; "mgr_wp_title" = "Papรฉis de Parede"; "mgr_browse_title" = "Explorar"; "add_wallpaper" = "Adicionar Papel de Parede"; @@ -29,12 +29,12 @@ "sb_refresh" = "Atualizar"; "sb_open_mgr" = "Abrir Gerenciador"; "sb_settings" = "Configuraรงรตes"; -"sb_about" = "Sobre moonleaf"; -"sb_about_text" = "Versรฃo %@ (%@)\n\nO Gerenciador de Papรฉis de Parede em Vivo para macOS"; +"sb_about" = "Sobre Petalia"; +"sb_about_text" = "Versรฃo %@ (%@)\nO Gerenciador de Papรฉis de Parede em Vivo para macOS"; "sb_about_github" = "GitHub"; "sb_about_kofi" = "Apoiar no Ko-fi"; "sb_perst_tooltip" = "Iniciar papel de parede automaticamente ao fazer login"; -"sb_quit" = "Encerrar moonleaf"; +"sb_quit" = "Encerrar Petalia"; "mgr_settings" = "configuraรงรตes"; "settings" = "Configuraรงรตes"; @@ -95,7 +95,7 @@ "settings_general" = "Geral"; "settings_startup" = "Inicializaรงรฃo"; "settings_auto_start" = "Iniciar ao Fazer Login"; -"settings_auto_start_desc" = "Iniciar moonleaf automaticamente ao fazer login"; +"settings_auto_start_desc" = "Iniciar Petalia automaticamente ao fazer login"; "settings_updates" = "Atualizaรงรตes"; "settings_check_updates" = "Verificar atualizaรงรตes"; "settings_check_updates_desc" = "Verificar atualizaรงรตes ao iniciar"; diff --git a/latest b/latest index 857572f..d3845ad 100644 --- a/latest +++ b/latest @@ -1 +1 @@ -v4.0.0 +v5.0.0 diff --git a/moonleaf/main/Components.swift b/moonleaf/main/Components.swift deleted file mode 100644 index 695547b..0000000 --- a/moonleaf/main/Components.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// Components.swift -// moonleaf -// -// Copyright ยฉ 2026 naomisphere. All rights reserved. -// - -import SwiftUI - -struct RippleButton: View { - var cornerRadius: CGFloat = 14 - let action: () -> Void - @ViewBuilder let label: () -> Label - - @AppStorage("enableRipple") private var enableRipple = false - @State private var rippleScale: CGFloat = 0 - @State private var rippleOpacity: Double = 0 - - var body: some View { - Button(action: action) { - ZStack { - label() - - if enableRipple { - Circle() - .fill(.white.opacity(0.25)) - .scaleEffect(rippleScale) - .opacity(rippleOpacity) - .frame(width: 80, height: 80) - .allowsHitTesting(false) - } - } - } - .buttonStyle(.plain) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .contentShape(Rectangle()) - .onTapGesture { if enableRipple { fire() } else { action() } } - } - - private func fire() { - rippleScale = 0 - rippleOpacity = 0.7 - withAnimation(.easeOut(duration: 0.45)) { - rippleScale = 2.2 - rippleOpacity = 0 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { action() } - } -} - -struct FavoriteButton: View { - let isFavorite: Bool - let action: () -> Void - - @State private var isHovered = false - @State private var bounce = false - - var body: some View { - Button(action: { - withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { bounce.toggle() } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { bounce = false } - } - action() - }) { - Image(systemName: isFavorite ? "star.fill" : "star") - .font(Font(font_loader.bold(size: 14))) - .foregroundStyle(isFavorite ? .yellow : .white) - .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 1) - .scaleEffect(bounce ? 1.4 : (isHovered ? 1.15 : 1.0)) - .frame(width: 32, height: 32) - .background { - Circle() - .fill(.black.opacity(isFavorite ? 0.6 : 0.35)) - } - } - .buttonStyle(.plain) - .onHover { hovering in withAnimation(.easeOut(duration: 0.15)) { isHovered = hovering } } - } -} diff --git a/moonleaf/main/ContentView.swift b/moonleaf/main/ContentView.swift deleted file mode 100644 index 5fd0cc3..0000000 --- a/moonleaf/main/ContentView.swift +++ /dev/null @@ -1,263 +0,0 @@ -// -// ContentView.swift -// moonleaf -// -// Copyright ยฉ 2026 naomisphere. All rights reserved. -// - -import SwiftUI - -struct ContentView: View { - @State private var selectedTab: TabSelection = .wallpapers - @State private var version_label_alpha: Double = 0.6 - @AppStorage("glassBackground") private var glassBackground = false - - private func app_version() -> String { - if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String, - let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { - return "\(version) (\(build))" - } - return "unknown" - } - - enum TabSelection: CaseIterable { - case wallpapers - case browse - - var title: String { - switch self { - case .wallpapers: return NSLocalizedString("mgr_wp_title", comment: "wallpapers") - case .browse: return NSLocalizedString("mgr_browse_title", comment: "browse") - } - } - - var icon: String { - switch self { - case .wallpapers: return "photo.on.rectangle" - case .browse: return "globe" - } - } - } - - var body: some View { - ZStack { - Color.clear - - VStack(spacing: 0) { - HStack(spacing: 20) { - HStack(spacing: 12) { - HStack(spacing: 12) { - if let ml_logo = NSImage(named: ".moonleaf_logo") { - Image(nsImage: ml_logo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 42, height: 42) - } - } - - Text(NSLocalizedString("mgr_title", comment: "moonleaf")) - .font(Font(font_loader.bold(size: 28))) - .foregroundStyle(.primary.opacity(0.9)) - - Text(app_version()) - .font(Font(font_loader.regular(size: 12))) - .foregroundStyle(.secondary.opacity(version_label_alpha)) - .padding(.leading, 4) - .padding(.top, 4) - } - - Spacer() - - HStack(spacing: 16) { - Button(action: { - if let url = URL(string: "https://ko-fi.com/naomisphere") { - NSWorkspace.shared.open(url) - } - }) { - if let kofi_cup = NSImage(named: ".kofi") { - Image(nsImage: kofi_cup) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .frame(width: 36, height: 36) - .background { - RoundedRectangle(cornerRadius: 12) - .fill(.regularMaterial.opacity(0.6)) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(.primary.opacity(0.2), lineWidth: 1) - } - } - } - } - .buttonStyle(.plain) - - ForEach(TabSelection.allCases, id: \.self) { tab in - tabButton( - title: tab.title, - icon: tab.icon, - isSelected: selectedTab == tab, - action: { - withAnimation(.easeInOut(duration: 0.3)) { selectedTab = tab } - } - ) - } - } - } - .padding(.horizontal, 32) - .padding(.vertical, 24) - .background(bg(useGlass: glassBackground)) - .padding(.horizontal, 16) - - ZStack { - Group { - switch selectedTab { - case .wallpapers: - ManagerView() - case .browse: - BrowseView() - } - } - .transition(.opacity) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .focusable(false) - } - } -} - -struct tabButton: View { - let title: String - let icon: String - let isSelected: Bool - let action: () -> Void - - @State private var isHovered = false - - var body: some View { - RippleButton(action: action) { - HStack(spacing: 8) { - Image(systemName: icon) - .font(Font(font_loader.regular(size: 14))) - Text(title) - .font(Font(font_loader.regular(size: 14))) - } - .foregroundStyle(isSelected ? .primary : .secondary) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background { - if isSelected { - RoundedRectangle(cornerRadius: 12) - .fill(.regularMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(.primary.opacity(0.1), lineWidth: 1) - } - } else if isHovered { - RoundedRectangle(cornerRadius: 12) - .fill(.quaternary.opacity(0.3)) - } - } - } - .onHover { hovering in - withAnimation(.easeOut(duration: 0.2)) { isHovered = hovering } - } - } -} - -struct bg: View { - var useGlass: Bool = true - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 20) - .fill(useGlass ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(.regularMaterial)) - - RoundedRectangle(cornerRadius: 20) - .fill(Color(red: 0.75, green: 0.85, blue: 1.0).opacity(0.12)) - - VStack(spacing: 0) { - Spacer() - AnimatedWaveView() - .frame(height: 75) - .opacity(0.9) - } - .clipShape(RoundedRectangle(cornerRadius: 20)) - - RoundedRectangle(cornerRadius: 20) - .stroke(.primary.opacity(0.08), lineWidth: 0.5) - } - } -} - -struct GPUWave: View { - var amplitude: CGFloat - var frequency: CGFloat - var duration: Double - var color: Color - var lineWidth: CGFloat - var reverse: Bool - var isFilled: Bool = false - - @State private var isAnimating = false - - var body: some View { - GeometryReader { geo in - let w = geo.size.width - let path = makePath(width: w, height: geo.size.height) - - Group { - if isFilled { - path.fill(color) - } else { - path.stroke(color, lineWidth: lineWidth) - } - } - .offset(x: isAnimating ? (reverse ? w : -w) : 0) - .offset(x: reverse ? -w : 0) - .frame(width: w * 2, alignment: .leading) - .onAppear { - withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) { - isAnimating = true - } - } - } - } - - func makePath(width: CGFloat, height: CGFloat) -> Path { - var path = Path() - let mid = isFilled ? 0 : height / 2 - let step: CGFloat = 5 - let totalWidth = width * 2 - - if isFilled { path.move(to: CGPoint(x: 0, y: height)) } - path.addLine(to: CGPoint(x: 0, y: mid)) - - for x in stride(from: step, through: totalWidth + step, by: step) { - let relativeX = x / width - let sine = sin(relativeX * frequency * .pi * 2) - path.addLine(to: CGPoint(x: x, y: mid + sine * amplitude)) - } - - if isFilled { - path.addLine(to: CGPoint(x: totalWidth + step, y: height)) - path.closeSubpath() - } - return path - } -} - -struct AnimatedWaveView: View { - var body: some View { - ZStack { - GPUWave(amplitude: 15, frequency: 1, duration: 8.0, color: Color(red: 137/255, green: 156/255, blue: 232/255).opacity(0.20), lineWidth: 0, reverse: false, isFilled: true) - .padding(.top, 15) - GPUWave(amplitude: 10, frequency: 2, duration: 5.5, color: Color(red: 137/255, green: 156/255, blue: 232/255).opacity(0.35), lineWidth: 0, reverse: true, isFilled: true) - .padding(.top, 30) - - GPUWave(amplitude: 14, frequency: 1, duration: 7.0, color: Color(red: 137/255, green: 156/255, blue: 232/255).opacity(0.5), lineWidth: 2.5, reverse: false) - GPUWave(amplitude: 8, frequency: 2, duration: 4.5, color: Color(red: 137/255, green: 156/255, blue: 232/255).opacity(0.6), lineWidth: 1.5, reverse: true) - } - } -} diff --git a/moonleaf/utils/QuickPreviewOverlay.swift b/moonleaf/utils/QuickPreviewOverlay.swift deleted file mode 100644 index 0bed414..0000000 --- a/moonleaf/utils/QuickPreviewOverlay.swift +++ /dev/null @@ -1,258 +0,0 @@ -// -// QuickPreviewOverlay.swift -// moonleaf -// -// Copyright ยฉ 2026 naomisphere. All rights reserved. -// - -import SwiftUI -import AVKit - -struct QuickPreviewOverlay: View { - let wallpaper: endup_wp - let onClose: () -> Void - - @State private var isHovered = false - @State private var currentTime = Date() - @State private var isVisible = false - @State private var player: AVPlayer? - @State private var isVideoPlaying = true - - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - - private var fileExtension: String { - (wallpaper.path as NSString).pathExtension.lowercased() - } - - var body: some View { - ZStack { - - Color.black - .ignoresSafeArea() - - - Group { - let ext = fileExtension - - if ["mp4", "mov"].contains(ext) { - - if let player = player { - VideoPlayer(player: player) - .onAppear { - if isVideoPlaying { - player.play() - } - } - .onDisappear { - player.pause() - } - .onTapGesture { - if isVideoPlaying { - player.pause() - } else { - player.play() - } - isVideoPlaying.toggle() - } - .overlay( - Group { - if !isVideoPlaying { - Image(systemName: "play.circle.fill") - .font(.system(size: 50)) - .foregroundColor(.white.opacity(0.6)) - .shadow(color: .black.opacity(0.5), radius: 10) - } - } - ) - } else { - Color.gray.opacity(0.3) - .overlay { - ProgressView() - .progressViewStyle(.circular) - .tint(.white) - } - } - } else if ext == "gif" { - - GIFAnimatingView(path: wallpaper.path) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if ["jpg", "jpeg", "png"].contains(ext) { - - if let image = NSImage(contentsOfFile: wallpaper.path) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - Color.gray.opacity(0.3) - .overlay { - Image(systemName: "exclamationmark.triangle") - .font(.largeTitle) - .foregroundColor(.white.opacity(0.5)) - } - } - } else { - Color.gray.opacity(0.3) - .overlay { - Image(systemName: "photo") - .font(.largeTitle) - .foregroundColor(.white.opacity(0.5)) - } - } - } - .ignoresSafeArea() - - - VStack(spacing: 0) { - HStack { - Spacer() - VStack(spacing: 0) { - - Text(formatDate(currentTime)) - .font(.system(size: 20, weight: .medium, design: .rounded)) - .foregroundColor(.white.opacity(0.9)) - .padding(.bottom, -3) - - - Text(formatTime(currentTime)) - .font(.system(size: 96, weight: .semibold, design: .rounded)) - .foregroundColor(.white.opacity(0.95)) - } - .padding(.top, 60) - Spacer() - } - Spacer() - } - .ignoresSafeArea() - - - VStack { - Spacer() - - HStack(spacing: 20) { - - VStack(alignment: .leading, spacing: 2) { - Text(wallpaper.name) - .font(.system(size: 14, weight: .medium, design: .rounded)) - .foregroundColor(.white.opacity(0.9)) - - let ext = fileExtension - let fileType = ["mp4", "mov"].contains(ext) ? "Video" : - ext == "gif" ? "GIF" : - ["jpg", "jpeg", "png"].contains(ext) ? "Image" : "File" - Text("\(fileType) โ€ข \(ByteCountFormatter.string(fromByteCount: wallpaper.fileSize, countStyle: .file))") - .font(.system(size: 11, weight: .regular, design: .rounded)) - .foregroundColor(.white.opacity(0.5)) - } - - Spacer() - - - if ["mp4", "mov"].contains(fileExtension) { - Button(action: { - if isVideoPlaying { - player?.pause() - } else { - player?.play() - } - isVideoPlaying.toggle() - }) { - Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 22)) - .foregroundColor(.white) - .shadow(color: .black.opacity(0.5), radius: 5) - } - .buttonStyle(.plain) - .padding(.trailing, 8) - } - - - Button(action: onClose) { - HStack(spacing: 8) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 16)) - Text("Close Preview") - .font(.system(size: 13, weight: .medium, design: .rounded)) - } - .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background( - Capsule() - .fill(.regularMaterial.opacity(0.8)) - .overlay { - Capsule() - .stroke(.white.opacity(0.2), lineWidth: 1) - } - ) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeOut(duration: 0.15)) { - isHovered = hovering - } - } - // .scaleEffect(isHovered ? 1.05 : 1.0) - - } - .padding(.horizontal, 40) - .padding(.bottom, 40) - } - .opacity(isVisible ? 1 : 0) - .animation(.easeOut(duration: 0.3).delay(0.2), value: isVisible) - } - .onAppear { - - let ext = fileExtension - if ["mp4", "mov"].contains(ext) { - let url = URL(fileURLWithPath: wallpaper.path) - player = AVPlayer(url: url) - player?.volume = 0 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - player?.play() - } - } - - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isVisible = true - } - } - .onDisappear { - player?.pause() - player = nil - } - .onReceive(timer) { _ in - currentTime = Date() - } - } - - private func formatTime(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm" - return formatter.string(from: date) - } - - private func formatDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "EEEE, MMMM d" - return formatter.string(from: date) - } -} - -struct GIFAnimatingView: NSViewRepresentable { - let path: String - - func makeNSView(context: Context) -> NSImageView { - let imageView = NSImageView() - imageView.imageScaling = .scaleProportionallyUpOrDown - imageView.animates = true - return imageView - } - - func updateNSView(_ nsView: NSImageView, context: Context) { - if let image = NSImage(contentsOfFile: path) { - nsView.image = image - } - } -} \ No newline at end of file diff --git a/petalia/Assets.xcassets/Contents.json b/petalia/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/petalia/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/Contents.json b/petalia/Assets.xcassets/main colors/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/petalia/Assets.xcassets/main colors/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/Secondary-surfece.colorset/Contents.json b/petalia/Assets.xcassets/main colors/Secondary-surfece.colorset/Contents.json new file mode 100644 index 0000000..1ccc619 --- /dev/null +++ b/petalia/Assets.xcassets/main colors/Secondary-surfece.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0.953", + "green" : "0.953", + "red" : "0.953" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.500", + "blue" : "0.137", + "green" : "0.131", + "red" : "0.126" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/accent.colorset/Contents.json b/petalia/Assets.xcassets/main colors/accent.colorset/Contents.json new file mode 100644 index 0000000..b1514de --- /dev/null +++ b/petalia/Assets.xcassets/main colors/accent.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.998", + "green" : "0.713", + "red" : "0.785" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.998", + "green" : "0.713", + "red" : "0.785" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/border-color.colorset/Contents.json b/petalia/Assets.xcassets/main colors/border-color.colorset/Contents.json new file mode 100644 index 0000000..b500136 --- /dev/null +++ b/petalia/Assets.xcassets/main colors/border-color.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.800", + "blue" : "0.851", + "green" : "0.851", + "red" : "0.851" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.600", + "blue" : "0.239", + "green" : "0.239", + "red" : "0.252" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/content-100.colorset/Contents.json b/petalia/Assets.xcassets/main colors/content-100.colorset/Contents.json new file mode 100644 index 0000000..9f1dbb1 --- /dev/null +++ b/petalia/Assets.xcassets/main colors/content-100.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.091", + "green" : "0.082", + "red" : "0.075" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.972", + "green" : "0.976", + "red" : "0.969" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/content-200.colorset/Contents.json b/petalia/Assets.xcassets/main colors/content-200.colorset/Contents.json new file mode 100644 index 0000000..6dbb99d --- /dev/null +++ b/petalia/Assets.xcassets/main colors/content-200.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.361", + "green" : "0.361", + "red" : "0.361" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.580", + "green" : "0.600", + "red" : "0.580" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/main colors/main-surfece.colorset/Contents.json b/petalia/Assets.xcassets/main colors/main-surfece.colorset/Contents.json new file mode 100644 index 0000000..8f9208b --- /dev/null +++ b/petalia/Assets.xcassets/main colors/main-surfece.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.972", + "green" : "0.976", + "red" : "0.969" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.091", + "green" : "0.082", + "red" : "0.075" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/petalia/Assets.xcassets/petalia.imageset/Contents.json b/petalia/Assets.xcassets/petalia.imageset/Contents.json new file mode 100644 index 0000000..f3a8723 --- /dev/null +++ b/petalia/Assets.xcassets/petalia.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Curves.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/petalia/Assets.xcassets/petalia.imageset/Curves.png b/petalia/Assets.xcassets/petalia.imageset/Curves.png new file mode 100644 index 0000000..648c59e Binary files /dev/null and b/petalia/Assets.xcassets/petalia.imageset/Curves.png differ diff --git a/petalia/main/BrowseState.swift b/petalia/main/BrowseState.swift new file mode 100644 index 0000000..8130cb9 --- /dev/null +++ b/petalia/main/BrowseState.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Combine + +class BrowseState: ObservableObject { + static let shared = BrowseState() + + @Published var searchQuery = "" + @Published var isSearchExpanded = false + @Published var showSortDropdown = false + @Published var showFilters = false + + @Published var chosen_prov: Set = [.wallhaven] + @Published var chosen_categ: WHCategory = .all + @Published var chosen_purity: Set = [.sfw] + @Published var selectedResolution: ResolutionFilter = .all + @Published var activeResolutionFilter: ResolutionFilter = .all + @Published var selectedColor: String? = nil + + let resetPageAndLoadTrigger = PassthroughSubject() +} + +enum ResolutionFilter: String, CaseIterable { + case hd = "HD" + case fullHd = "Full HD" + case wqhd = "WQHD" + case uhd4k = "4K UHD" + case all = "All" + + var displayName: String { + switch self { + case .hd: return "HD" + case .fullHd: return "Full HD" + case .wqhd: return "WQHD" + case .uhd4k: return "4K" + case .all: return NSLocalizedString("filter_resolution_all", comment: "All") + } + } + + func matches(width: Int, height: Int) -> Bool { + switch self { + case .all: return true + case .hd: + let totalPixels = width * height + return totalPixels >= 800_000 && totalPixels < 1_500_000 + case .fullHd: + let totalPixels = width * height + return totalPixels >= 1_500_000 && totalPixels < 3_000_000 + case .wqhd: + let totalPixels = width * height + return totalPixels >= 3_000_000 && totalPixels < 5_000_000 + case .uhd4k: + let totalPixels = width * height + return totalPixels >= 5_000_000 + } + } +} diff --git a/petalia/main/BrowseTopActionsView.swift b/petalia/main/BrowseTopActionsView.swift new file mode 100644 index 0000000..59b8107 --- /dev/null +++ b/petalia/main/BrowseTopActionsView.swift @@ -0,0 +1,227 @@ +import SwiftUI + +struct BrowseTopActionsView: View { + @StateObject private var state = BrowseState.shared + @AppStorage("browse_sorting") private var chosen_sorting: WHSort = .date_added + @AppStorage("browse_order") private var chosen_order: WHOrder = .desc + @AppStorage("pexelsAPIKey") private var pexelsAPIKeyStr: String = "" // Wait, API Key logic? + + var body: some View { + HStack(spacing: 12) { + // Search Button + if state.isSearchExpanded { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 14)) + + TextField(NSLocalizedString("browse_search_placeholder", comment: "Search..."), text: $state.searchQuery) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 14)) + .onSubmit { + state.resetPageAndLoadTrigger.send() + } + + if !state.searchQuery.isEmpty { + Button(action: { + state.searchQuery = "" + state.resetPageAndLoadTrigger.send() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } else { + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + state.isSearchExpanded = false + } + }) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .frame(height: 32) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + .frame(width: 250) + .transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .trailing))) + } else { + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + state.isSearchExpanded = true + } + }) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .transition(.opacity.combined(with: .scale(scale: 0.9, anchor: .trailing))) + } + + // Sort Button + Button(action: { state.showSortDropdown.toggle() }) { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .popover(isPresented: $state.showSortDropdown, arrowEdge: .bottom) { + sortDropdownView + } + + // Filters Button + Button(action: { state.showFilters.toggle() }) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .popover(isPresented: $state.showFilters) { + filtersView + .frame(width: 300) + .padding() + } + } + } + + private var sortDropdownView: some View { + VStack(spacing: 4) { + ForEach(WHSort.allCases, id: \.self) { sorting in + Button(action: { + chosen_sorting = sorting + state.showSortDropdown = false + state.resetPageAndLoadTrigger.send() + }) { + HStack { + Text(sorting.displayName) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(chosen_sorting == sorting ? .primary : .secondary) + Spacer() + if chosen_sorting == sorting { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(chosen_sorting == sorting ? 0.08 : 0.001))) + } + .buttonStyle(.plain) + } + + Divider() + .padding(.vertical, 4) + + ForEach([WHOrder.desc, WHOrder.asc], id: \.self) { order in + Button(action: { + chosen_order = order + state.showSortDropdown = false + state.resetPageAndLoadTrigger.send() + }) { + HStack { + Text(order == .desc ? NSLocalizedString("browse_desc", comment: "") : NSLocalizedString("browse_asc", comment: "")) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(chosen_order == order ? .primary : .secondary) + Spacer() + if chosen_order == order { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(chosen_order == order ? 0.08 : 0.001))) + } + .buttonStyle(.plain) + } + } + .padding(8) + .frame(width: 160) + } + + private var filtersView: some View { + VStack(alignment: .leading, spacing: 20) { + Text(NSLocalizedString("browse_filters", comment: "Filters")) + .font(Font(font_loader.bold(size: 16))) + + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("browse_provider", comment: "Source")) + .font(Font(font_loader.regular(size: 12))) + .foregroundColor(.secondary) + + MultiSegmentSelector( + options: WallpaperProvider.allCases, + selection: $state.chosen_prov, + displayName: { $0.displayName }, + icon: { $0.icon } + ) + } + + if state.chosen_prov.contains(.wallhaven) { + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("browse_purity", comment: "Purity")) + .font(Font(font_loader.regular(size: 12))) + .foregroundColor(.secondary) + + MultiSegmentSelector( + options: WHPurityStatus.allCases, + selection: $state.chosen_purity, + displayName: { $0.displayName }, + icon: nil + ) + } + } + + Button(action: { + state.showFilters = false + state.resetPageAndLoadTrigger.send() + }) { + Text(NSLocalizedString("browse_apply", comment: "Apply Filters")) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .frame(maxWidth: .infinity) + } + .buttonStyle(AccentButtonStyle()) + } + } +} diff --git a/moonleaf/main/BrowseView.swift b/petalia/main/BrowseView.swift similarity index 51% rename from moonleaf/main/BrowseView.swift rename to petalia/main/BrowseView.swift index f4b962b..5c7f829 100644 --- a/moonleaf/main/BrowseView.swift +++ b/petalia/main/BrowseView.swift @@ -1,98 +1,122 @@ // // BrowseView.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // import SwiftUI import Combine -import AVKit struct BrowseView: View { @StateObject private var WHServ = WHService() @StateObject private var pexelsServ = PexelsService() - @State private var searchQuery = "" - @State private var chosen_sorting: WHSort = .date_added - @State private var chosen_order: WHOrder = .desc - @State private var chosen_purity: WHPurityStatus = .sfw - @State private var chosen_categ: WHCategory = .all - @State private var chosen_prov: Set = [.wallhaven] - @State private var selectedResolution: ResolutionFilter = .all - @State private var activeResolutionFilter: ResolutionFilter = .all + @StateObject private var state = BrowseState.shared + @AppStorage("browse_sorting") private var chosen_sorting: WHSort = .date_added + @AppStorage("browse_order") private var chosen_order: WHOrder = .desc + + private var cachedWallpapers: [AnyWallpaper] { + var items: [AnyWallpaper] = [] + if state.chosen_prov.contains(.wallhaven) { + items.append(contentsOf: WHServ.wallpapers) + } + if state.chosen_prov.contains(.pexels) { + items.append(contentsOf: pexelsServ.videos) + } + return items.filter { item in + state.activeResolutionFilter.matches(width: item.width, height: item.height) + } + } + @State private var currentPage = 1 - @State private var showFilters = false @State private var isLoading = false + @State private var isPaginationLoading = false @State private var showAPIKeyAlert = false @State private var apiKey = "" @State private var pexelsAPIKey: String = "" @State private var showPexelsKeyAlert = false @State private var pexelsKeyError = false + @State private var showColorPopover = false - enum ResolutionFilter: String, CaseIterable { - case hd = "HD" - case fullHd = "Full HD" - case wqhd = "WQHD" - case uhd4k = "4K UHD" - case all = "All" - - var displayName: String { - switch self { - case .hd: return "HD" - case .fullHd: return "Full HD" - case .wqhd: return "WQHD" - case .uhd4k: return "4K" - case .all: return NSLocalizedString("filter_resolution_all", comment: "All") - } - } - - func matches(width: Int, height: Int) -> Bool { - switch self { - case .all: - return true - case .hd: - let totalPixels = width * height - return totalPixels >= 800_000 && totalPixels < 1_500_000 - case .fullHd: - let totalPixels = width * height - return totalPixels >= 1_500_000 && totalPixels < 3_000_000 - case .wqhd: - let totalPixels = width * height - return totalPixels >= 3_000_000 && totalPixels < 5_000_000 - case .uhd4k: - let totalPixels = width * height - return totalPixels >= 5_000_000 - } - } - } + var body: some View { - VStack(spacing: 0) { - searchBar - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 8) - - if isLoading { - loadingView - } else if filteredWallpapers.isEmpty { - emptyStateView - } else { - contentView - } + GeometryReader { geo in + let width = geo.size.width + let spacing: CGFloat = 20 + let columnCount = max(2, Int((width - 48) / (260 + spacing))) + let colWidth = (width - 48 - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount) - if chosen_prov.contains(.pexels) { - Button(action: { - if let url = URL(string: "https://www.pexels.com") { - NSWorkspace.shared.open(url) + ScrollView { + VStack(spacing: 0) { + Spacer().frame(height: 48) + + toolbarView + + ZStack { + if isLoading { + loadingView + .frame(minHeight: max(200, geo.size.height - 180)) + } else if cachedWallpapers.isEmpty { + emptyStateView + .frame(minHeight: max(200, geo.size.height - 180)) + } else { + LazyMasonryContent( + wallpapers: cachedWallpapers, + columnCount: columnCount, + spacing: spacing, + colWidth: colWidth, + lastItemID: cachedWallpapers.last?.id, + onLastAppeared: loadNextPage + ) + .padding(.horizontal, 24) + .padding(.bottom, 32) + .padding(.top, 8) + } + } + + if state.chosen_prov.contains(.pexels) { + Button(action: { + if let url = URL(string: "https://www.pexels.com") { + NSWorkspace.shared.open(url) + } + }) { + Text("Photos provided by Pexels") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary.opacity(0.6)) + .padding(.vertical, 8) + } + .buttonStyle(.plain) } - }) { - Text("Photos provided by Pexels") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary.opacity(0.6)) - .padding(.vertical, 8) } - .buttonStyle(.plain) + } + } + .onReceive(state.resetPageAndLoadTrigger) { _ in + currentPage = 1 + loadWallpapers() + } + .onChange(of: state.chosen_categ) { _ in + currentPage = 1 + loadWallpapers() + } + .onChange(of: state.selectedResolution) { newValue in + state.activeResolutionFilter = newValue + currentPage = 1 + loadWallpapers() + } + .onChange(of: state.selectedColor) { _ in + currentPage = 1 + loadWallpapers() + } + .onChange(of: state.chosen_prov) { newValue in + if newValue.contains(.pexels) && pexelsAPIKey.isEmpty { + DispatchQueue.main.async { + state.chosen_prov.remove(.pexels) + showPexelsKeyAlert = true + } + } else { + currentPage = 1 + loadWallpapers() } } .onAppear { @@ -111,7 +135,7 @@ struct BrowseView: View { pexelsAPIKey = keyToTry savePexelsAPIKey(keyToTry) pexelsServ.setAPIKey(keyToTry) - chosen_prov.insert(.pexels) + state.chosen_prov.insert(.pexels) apiKey = "" loadWallpapers() } else { @@ -133,8 +157,87 @@ struct BrowseView: View { } } + private var toolbarView: some View { + HStack(alignment: .bottom) { + Text("Browse") + .font(.system(size: 48, weight: .bold)) + .foregroundStyle(.primary) + + Spacer() + + HStack(spacing: 12) { + SegmentSelector( + options: WHCategory.allCases, + selection: $state.chosen_categ, + displayName: { $0.displayName } + ) + + SegmentSelector( + options: ResolutionFilter.allCases, + selection: $state.selectedResolution, + displayName: { $0.displayName } + ) + + HStack(spacing: 0) { + Button(action: { showColorPopover.toggle() }) { + HStack(spacing: 6) { + if let colorHex = state.selectedColor { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 12, height: 12) + .overlay { + Circle().stroke(.primary.opacity(0.15), lineWidth: 0.5) + } + } else { + Circle() + .fill(LinearGradient(colors: [.red, .green, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 12, height: 12) + } + + Text("Color") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(state.selectedColor != nil ? Color.primary : Color.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + if state.selectedColor != nil { + Capsule() + .fill(Color.mainSurface) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(3) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + .popover(isPresented: $showColorPopover, arrowEdge: .bottom) { + colorPopoverView + } + } + .padding(.bottom, 6) + } + .padding(.horizontal, 32) + .padding(.top, 8) + .padding(.bottom, 8) + .frame(maxWidth: .infinity) + } + private func loadPexelsAPIKey() { - let configDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/moonleaf") + let configDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/petalia") let keyFile = configDir.appendingPathComponent("pexels_api_key.txt") do { @@ -146,7 +249,7 @@ struct BrowseView: View { } private func savePexelsAPIKey(_ key: String) { - let configDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/moonleaf") + let configDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".config/petalia") let keyFile = configDir.appendingPathComponent("pexels_api_key.txt") do { @@ -156,214 +259,8 @@ struct BrowseView: View { } } - private var searchBar: some View { - HStack(spacing: 12) { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - .font(Font(font_loader.regular(size: 14))) - - TextField(NSLocalizedString("browse_search_placeholder", comment: "Search wallpapers..."), text: $searchQuery) - .textFieldStyle(PlainTextFieldStyle()) - .font(Font(font_loader.regular(size: 14))) - .onSubmit { - currentPage = 1 - loadWallpapers() - } - - if !searchQuery.isEmpty { - Button(action: { - searchQuery = "" - currentPage = 1 - loadWallpapers() - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - .buttonStyle(PlainButtonStyle()) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background { - RoundedRectangle(cornerRadius: 12) - .fill(.regularMaterial) - .overlay { - RoundedRectangle(cornerRadius: 12) - .stroke(.primary.opacity(0.1), lineWidth: 1) - } - } - - Button(action: { - showFilters.toggle() - }) { - Image(systemName: "slider.horizontal.3") - .font(Font(font_loader.regular(size: 14))) - .foregroundColor(.primary) - .padding(10) - .background { - RoundedRectangle(cornerRadius: 10) - .fill(.regularMaterial) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(.primary.opacity(0.1), lineWidth: 1) - } - } - } - .buttonStyle(PlainButtonStyle()) - .popover(isPresented: $showFilters) { - filtersView - .frame(width: 300) - .padding() - } - } - } - - private var filtersView: some View { - VStack(alignment: .leading, spacing: 20) { - Text(NSLocalizedString("browse_filters", comment: "Filters")) - .font(Font(font_loader.bold(size: 16))) - - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("browse_provider", comment: "Source")) - .font(Font(font_loader.regular(size: 12))) - .foregroundColor(.secondary) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 8) { - ForEach(WallpaperProvider.allCases, id: \.self) { provider in - providerToggle(provider: provider) - } - } - } - - if chosen_prov.contains(.wallhaven) { - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("browse_category", comment: "Category")) - .font(Font(font_loader.regular(size: 12))) - .foregroundColor(.secondary) - - Picker("", selection: $chosen_categ) { - ForEach(WHCategory.allCases, id: \.self) { category in - Text(category.displayName).tag(category) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("browse_purity", comment: "Purity")) - .font(Font(font_loader.regular(size: 12))) - .foregroundColor(.secondary) - - Picker("", selection: $chosen_purity) { - ForEach(WHPurityStatus.allCases, id: \.self) { purity in - Text(purity.displayName).tag(purity) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - } - - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("browse_resolution", comment: "Resolution")) - .font(Font(font_loader.regular(size: 12))) - .foregroundColor(.secondary) - - Picker("", selection: $selectedResolution) { - ForEach(ResolutionFilter.allCases, id: \.self) { resolution in - Text(resolution.displayName).tag(resolution) - } - } - .pickerStyle(SegmentedPickerStyle()) - } - - VStack(alignment: .leading, spacing: 12) { - Text(NSLocalizedString("browse_sort", comment: "Sort by")) - .font(Font(font_loader.regular(size: 12))) - .foregroundColor(.secondary) - - Picker("", selection: $chosen_sorting) { - ForEach(WHSort.allCases, id: \.self) { sorting in - Text(sorting.displayName).tag(sorting) - } - } - .pickerStyle(MenuPickerStyle()) - - Picker("", selection: $chosen_order) { - Text(NSLocalizedString("browse_desc", comment: "Descending")).tag(WHOrder.desc) - Text(NSLocalizedString("browse_asc", comment: "Ascending")).tag(WHOrder.asc) - } - .pickerStyle(SegmentedPickerStyle()) - } - - Button(action: { - activeResolutionFilter = selectedResolution - currentPage = 1 - loadWallpapers() - showFilters = false - }) { - Text(NSLocalizedString("browse_apply", comment: "Apply Filters")) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - .buttonStyle(.borderedProminent) - } - } - - private func providerToggle(provider: WallpaperProvider) -> some View { - Toggle(isOn: Binding( - get: { chosen_prov.contains(provider) }, - set: { isSelected in - if isSelected { - if provider == .pexels && pexelsAPIKey.isEmpty { - showPexelsKeyAlert = true - } else { - chosen_prov.insert(provider) - } - } else { - chosen_prov.remove(provider) - } - } - )) { - HStack(spacing: 6) { - Image(systemName: provider.icon) - .font(Font(font_loader.regular(size: 11))) - Text(provider.displayName) - .font(Font(font_loader.regular(size: 11))) - } - } - .toggleStyle(CBToggle()) - } - - private var contentView: some View { - ScrollView { - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 20), count: 3), spacing: 20) { - ForEach(filteredWallpapers) { wallpaper in - BrowseWallpaperCard(item: wallpaper) - .onAppear { - if wallpaper.id == filteredWallpapers.last?.id { - loadNextPage() - } - } - } - } - .padding(24) - } - } - - private var filteredWallpapers: [AnyWallpaper] { - var items: [AnyWallpaper] = [] - if chosen_prov.contains(.wallhaven) { - items.append(contentsOf: WHServ.wallpapers) - } - if chosen_prov.contains(.pexels) { - items.append(contentsOf: pexelsServ.videos) - } - return items.filter { item in - activeResolutionFilter.matches(width: item.width, height: item.height) - } - } + private var loadingView: some View { VStack(spacing: 20) { ProgressView() @@ -390,16 +287,18 @@ struct BrowseView: View { private func loadWallpapers() { isLoading = true + isPaginationLoading = false let group = DispatchGroup() - if chosen_prov.contains(.wallhaven) { + if state.chosen_prov.contains(.wallhaven) { group.enter() WHServ.searchWallpapers( - query: searchQuery.isEmpty ? nil : searchQuery, + query: state.searchQuery.isEmpty ? nil : state.searchQuery, sorting: chosen_sorting, order: chosen_order, - purity: chosen_purity, - category: chosen_categ, + purity: state.chosen_purity, + category: state.chosen_categ, + color: state.selectedColor, page: currentPage ) { group.leave() @@ -408,11 +307,11 @@ struct BrowseView: View { WHServ.wallpapers = [] } - if chosen_prov.contains(.pexels) { + if state.chosen_prov.contains(.pexels) { if !pexelsAPIKey.isEmpty { group.enter() pexelsServ.searchVideos( - query: searchQuery.isEmpty ? "nature" : searchQuery, + query: state.searchQuery.isEmpty ? "nature" : state.searchQuery, page: currentPage, perPage: 24 ) { @@ -431,23 +330,137 @@ struct BrowseView: View { } private func loadNextPage() { + guard !isPaginationLoading else { return } + isPaginationLoading = true + currentPage += 1 - if chosen_prov.contains(.wallhaven) { + let group = DispatchGroup() + + if state.chosen_prov.contains(.wallhaven) { + group.enter() WHServ.loadMoreWallpapers( - query: searchQuery.isEmpty ? nil : searchQuery, + query: state.searchQuery.isEmpty ? nil : state.searchQuery, sorting: chosen_sorting, order: chosen_order, - purity: chosen_purity, - category: chosen_categ, + purity: state.chosen_purity, + category: state.chosen_categ, + color: state.selectedColor, page: currentPage - ) + ) { + group.leave() + } } - if chosen_prov.contains(.pexels) && !pexelsAPIKey.isEmpty { + if state.chosen_prov.contains(.pexels) && !pexelsAPIKey.isEmpty { + group.enter() pexelsServ.loadMoreVideos( - query: searchQuery.isEmpty ? "nature" : searchQuery, + query: state.searchQuery.isEmpty ? "nature" : state.searchQuery, page: currentPage, perPage: 24 - ) + ) { + group.leave() + } + } + + group.notify(queue: .main) { + isPaginationLoading = false + } + } + + private let wallhavenColors = [ + "660000", "990000", "cc0000", "cc3333", "ea4c88", "993399", "663399", "333399", + "0066cc", "0099cc", "66cccc", "77cc33", "669900", "336600", "666600", "999900", + "cccc33", "ffff00", "ffcc33", "ff9900", "ff6600", "cc6633", "996633", "663300", + "000000", "999999", "cccccc", "ffffff", "424153" + ] + + private var colorPopoverView: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Select Color") + .font(.system(size: 13, weight: .semibold)) + + Spacer() + + if state.selectedColor != nil { + Button(action: { + state.selectedColor = nil + showColorPopover = false + }) { + Text("Clear") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.blue) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + + let columns = Array(repeating: GridItem(.fixed(20), spacing: 6), count: 6) + LazyVGrid(columns: columns, spacing: 6) { + ForEach(wallhavenColors, id: \.self) { colorHex in + Button(action: { + state.selectedColor = colorHex + showColorPopover = false + }) { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 20, height: 20) + .overlay { + Circle() + .stroke(state.selectedColor == colorHex ? Color.primary : Color.primary.opacity(0.15), lineWidth: state.selectedColor == colorHex ? 2.0 : 0.5) + } + .shadow(color: .black.opacity(0.05), radius: 1) + } + .buttonStyle(.plain) + } + } + } + .padding(10) + .frame(width: 170) + } +} + +private struct LazyMasonryContent: View { + let wallpapers: [AnyWallpaper] + let columnCount: Int + let spacing: CGFloat + let colWidth: CGFloat + let lastItemID: String? + let onLastAppeared: () -> Void + + private var columns: [[AnyWallpaper]] { + guard columnCount > 0 else { return [] } + var cols = Array(repeating: [AnyWallpaper](), count: columnCount) + var heights = Array(repeating: CGFloat(0), count: columnCount) + + for item in wallpapers { + let shortest = heights.enumerated().min(by: { $0.element < $1.element })?.offset ?? 0 + cols[shortest].append(item) + let ratio = CGFloat(item.width) / CGFloat(max(1, item.height)) + let clamped = min(max(ratio, 0.65), 2.4) + heights[shortest] += 1.0 / clamped + } + + return cols + } + + var body: some View { + HStack(alignment: .top, spacing: spacing) { + ForEach(0.. Bool { + lhs.id == rhs.id + } } extension WHWallpaper: WallpaperItem { @@ -515,281 +532,6 @@ extension WHWallpaper: WallpaperItem { var itemURL: URL? { URL(string: url) } } -struct BrowseWallpaperCard: View { - let item: AnyWallpaper - @State private var image: NSImage? - @State private var isLoading = true - @State private var isHovered = false - @State private var isDownloading = false - @State private var isDownloaded = false - @State private var downloadProgress: Double = 0 - @State private var downloadTask: URLSessionDownloadTask? - @State private var progressObservation: NSKeyValueObservation? - @State private var imageTask: URLSessionDataTask? - @State private var player: AVPlayer? - @State private var playerLayer: AVPlayerLayer? - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - ZStack { - if let image = image { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(height: 200) - .clipped() - } else { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(height: 200) - - if isLoading { - ProgressView() - } - } - - if item.isVideo { - VStack { - HStack { - Spacer() - Image(systemName: "play.circle.fill") - .font(.title) - .foregroundColor(.white) - .shadow(radius: 2) - } - Spacer() - } - .padding(8) - } - - if isHovered { - VStack { - HStack { - Spacer() - downloadButton - } - Spacer() - if item.provider == .pexels { - HStack { - if let author = item.authorName, let url = item.authorURL { - Button(action: { NSWorkspace.shared.open(url) }) { - Text("Photo by \(author)") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.white.opacity(0.8)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(Color.black.opacity(0.4))) - } - .buttonStyle(.plain) - } - Spacer() - Button(action: { if let url = item.itemURL { NSWorkspace.shared.open(url) } }) { - Text("on Pexels") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.white.opacity(0.8)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Capsule().fill(Color.black.opacity(0.4))) - } - .buttonStyle(.plain) - } - } - } - .padding(8) - } - - if isDownloading { - ZStack { - Circle() - .stroke(Color.white.opacity(0.2), lineWidth: 3) - - Circle() - .trim(from: 0, to: max(0.05, downloadProgress)) - .stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - } - .frame(width: 32, height: 32) - .background(Circle().fill(Color.black.opacity(0.4))) - } - } - .frame(height: 200) - .cornerRadius(12) - - Text(item.id) - .font(Font(font_loader.regular(size: 12))) - .lineLimit(1) - - Text("\(item.width)x\(item.height)") - .font(Font(font_loader.regular(size: 10))) - .foregroundColor(.secondary) - } - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.2)) { - isHovered = hovering - } - } - .onAppear { - loadPreview() - checkIfDownloaded() - } - .onDisappear { - downloadTask?.cancel() - progressObservation?.invalidate() - imageTask?.cancel() - } - } - - private var downloadButton: some View { - Button(action: { - if !isDownloaded { - downloadWallpaper() - } - }) { - ZStack { - if isDownloaded { - Image(systemName: "checkmark.circle.fill") - .font(Font(font_loader.regular(size: 20))) - .foregroundColor(.green) - } else if isDownloading { - ProgressView() - .scaleEffect(0.7) - .brightness(1) - } else { - Image(systemName: "arrow.down.circle.fill") - .font(Font(font_loader.regular(size: 20))) - .foregroundColor(.white) - } - } - .padding(8) - .background(Circle().fill(Color.black.opacity(0.6))) - } - .buttonStyle(PlainButtonStyle()) - .disabled(isDownloading || isDownloaded) - } - - private func checkIfDownloaded() { - guard let downloadURL = item.downloadURL else { return } - let home = FileManager.default.homeDirectoryForCurrentUser - let wpStorageDir = home.appendingPathComponent(".local/share/paper/wallpaper") - let fileName = "\(item.id)-\(downloadURL.lastPathComponent)" - let destinationURL = wpStorageDir.appendingPathComponent(fileName) - - if FileManager.default.fileExists(atPath: destinationURL.path) { - isDownloaded = true - } - } - - private func loadPreview() { - guard let previewURL = item.previewURL else { - isLoading = false - return - } - - let cacheKey = previewURL.absoluteString as NSString - if let cachedImage = ThumbnailCache.shared.getImage(forKey: cacheKey as String) { - self.image = cachedImage - self.isLoading = false - return - } - - imageTask = URLSession.shared.dataTask(with: previewURL) { data, response, error in - if let error = error { - DispatchQueue.main.async { - self.isLoading = false - } - return - } - - guard let data = data, let nsImage = NSImage(data: data) else { - DispatchQueue.main.async { - self.isLoading = false - } - return - } - - ThumbnailCache.shared.setImage(nsImage, forKey: cacheKey as String) - - DispatchQueue.main.async { - self.image = nsImage - self.isLoading = false - } - } - imageTask?.resume() - } - - private func downloadWallpaper(retryCount: Int = 0) { - guard let downloadURL = item.downloadURL else { return } - - let home = FileManager.default.homeDirectoryForCurrentUser - let wpStorageDir = home.appendingPathComponent(".local/share/paper/wallpaper") - - do { - try FileManager.default.createDirectory(at: wpStorageDir, withIntermediateDirectories: true) - - let fileName = "\(item.id)-\(downloadURL.lastPathComponent)" - let destinationURL = wpStorageDir.appendingPathComponent(fileName) - - if FileManager.default.fileExists(atPath: destinationURL.path) { - isDownloaded = true - return - } - - isDownloading = true - downloadProgress = 0 - - downloadTask = URLSession.shared.downloadTask(with: downloadURL) { tempURL, response, error in - DispatchQueue.main.async { - self.isDownloading = false - self.progressObservation?.invalidate() - self.progressObservation = nil - } - - if let error = error { - if retryCount < 2 { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.downloadWallpaper(retryCount: retryCount + 1) - } - } - return - } - - guard let tempURL = tempURL else { - return - } - - do { - if FileManager.default.fileExists(atPath: destinationURL.path) { - try? FileManager.default.removeItem(at: destinationURL) - } - try FileManager.default.moveItem(at: tempURL, to: destinationURL) - - DispatchQueue.main.async { - self.isDownloaded = true - NotificationCenter.default.post( - name: NSNotification.Name("WallpaperDownloadCompleted"), - object: nil - ) - } - } catch { - } - } - - progressObservation = downloadTask?.progress.observe(\.fractionCompleted) { progress, _ in - DispatchQueue.main.async { - self.downloadProgress = progress.fractionCompleted - } - } - - downloadTask?.resume() - - } catch { - isDownloading = false - downloadProgress = 0 - } - } -} - enum WallpaperProvider: String, CaseIterable { case wallhaven = "wallhaven" case pexels = "pexels" @@ -829,8 +571,9 @@ class WHService: ObservableObject { query: String? = nil, sorting: WHSort = .date_added, order: WHOrder = .desc, - purity: WHPurityStatus = .sfw, + purity: Set = [.sfw], category: WHCategory = .all, + color: String? = nil, page: Int = 1, completion: (() -> Void)? = nil ) { @@ -843,17 +586,34 @@ class WHService: ObservableObject { queryItems.append(URLQueryItem(name: "sorting", value: sorting.rawValue)) queryItems.append(URLQueryItem(name: "order", value: order.rawValue)) - queryItems.append(URLQueryItem(name: "purity", value: purity.rawValue)) + + var purityStr = "" + purityStr += purity.contains(.sfw) ? "1" : "0" + purityStr += purity.contains(.sketchy) ? "1" : "0" + purityStr += purity.contains(.nsfw) ? "1" : "0" + if purityStr == "000" { purityStr = "100" } + queryItems.append(URLQueryItem(name: "purity", value: purityStr)) + queryItems.append(URLQueryItem(name: "categories", value: category.rawValue)) - queryItems.append(URLQueryItem(name: "page", value: "\(page)")) + if let color = color, !color.isEmpty { + queryItems.append(URLQueryItem(name: "colors", value: color)) + } - if sorting == .random, let seed = currentSeed { - queryItems.append(URLQueryItem(name: "seed", value: seed)) + queryItems.append(URLQueryItem(name: "page", value: "\(page)")) + if sorting == .random { + if page == 1 { + currentSeed = nil + } else if let seed = currentSeed { + queryItems.append(URLQueryItem(name: "seed", value: seed)) + } } components.queryItems = queryItems - guard let url = components.url else { return } + guard let url = components.url else { + completion?() + return + } var request = URLRequest(url: url) if !apiKey.isEmpty { @@ -887,9 +647,11 @@ class WHService: ObservableObject { query: String? = nil, sorting: WHSort = .date_added, order: WHOrder = .desc, - purity: WHPurityStatus = .sfw, + purity: Set = [.sfw], category: WHCategory = .all, - page: Int + color: String? = nil, + page: Int, + completion: (() -> Void)? = nil ) { var components = URLComponents(string: baseURL)! var queryItems: [URLQueryItem] = [] @@ -900,17 +662,30 @@ class WHService: ObservableObject { queryItems.append(URLQueryItem(name: "sorting", value: sorting.rawValue)) queryItems.append(URLQueryItem(name: "order", value: order.rawValue)) - queryItems.append(URLQueryItem(name: "purity", value: purity.rawValue)) + + var purityStr = "" + purityStr += purity.contains(.sfw) ? "1" : "0" + purityStr += purity.contains(.sketchy) ? "1" : "0" + purityStr += purity.contains(.nsfw) ? "1" : "0" + if purityStr == "000" { purityStr = "100" } + queryItems.append(URLQueryItem(name: "purity", value: purityStr)) + queryItems.append(URLQueryItem(name: "categories", value: category.rawValue)) - queryItems.append(URLQueryItem(name: "page", value: "\(page)")) + if let color = color, !color.isEmpty { + queryItems.append(URLQueryItem(name: "colors", value: color)) + } + queryItems.append(URLQueryItem(name: "page", value: "\(page)")) if sorting == .random, let seed = currentSeed { queryItems.append(URLQueryItem(name: "seed", value: seed)) } components.queryItems = queryItems - guard let url = components.url else { return } + guard let url = components.url else { + completion?() + return + } var request = URLRequest(url: url) if !apiKey.isEmpty { @@ -918,6 +693,7 @@ class WHService: ObservableObject { } URLSession.shared.dataTask(with: request) { data, _, error in + defer { completion?() } if let _ = error { return } @@ -1007,8 +783,11 @@ class PexelsService: ObservableObject { }.resume() } - func loadMoreVideos(query: String, page: Int, perPage: Int = 15) { - guard !apiKey.isEmpty else { return } + func loadMoreVideos(query: String, page: Int, perPage: Int = 15, completion: (() -> Void)? = nil) { + guard !apiKey.isEmpty else { + completion?() + return + } var components = URLComponents(string: "\(baseURL)/search")! components.queryItems = [ @@ -1017,12 +796,16 @@ class PexelsService: ObservableObject { URLQueryItem(name: "page", value: "\(page)") ] - guard let url = components.url else { return } + guard let url = components.url else { + completion?() + return + } var request = URLRequest(url: url) request.addValue(apiKey, forHTTPHeaderField: "Authorization") URLSession.shared.dataTask(with: request) { data, _, error in + defer { completion?() } if let _ = error { return } guard let data = data else { return } @@ -1168,11 +951,15 @@ enum WHOrder: String { } enum WHPurityStatus: String, CaseIterable { - case sfw = "100" + case sfw = "sfw" + case sketchy = "sketchy" + case nsfw = "nsfw" var displayName: String { switch self { case .sfw: return "SFW" + case .sketchy: return "Sketchy" + case .nsfw: return "NSFW" } } } @@ -1210,4 +997,153 @@ class ThumbnailCache { let cost = Int(image.size.width * image.size.height * 4) cache.setObject(image, forKey: key as NSString, cost: cost) } +} + +struct SegmentSelector: View { + let options: [T] + @Binding var selection: T + let displayName: (T) -> String + + @Namespace private var ns + @State private var hoveredItem: T? = nil + + var body: some View { + HStack(spacing: 2) { + ForEach(options, id: \.self) { option in + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + selection = option + } + }) { + Text(displayName(option)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(selection == option ? Color.primary : (hoveredItem == option ? Color.primary.opacity(0.7) : Color.secondary)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + if selection == option { + Capsule() + .fill(Color.mainSurface) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) + .matchedGeometryEffect(id: "activeSegment", in: ns) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + hoveredItem = hovering ? option : nil + } + } + } + } + .padding(3) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (255, 0, 0, 0) + } + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} + +struct MultiSegmentSelector: View { + let options: [T] + @Binding var selection: Set + let displayName: (T) -> String + let icon: ((T) -> String)? + + @State private var hoveredItem: T? = nil + + var body: some View { + HStack(spacing: 2) { + ForEach(options, id: \.self) { option in + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + var newSelection = selection + if newSelection.contains(option) { + if newSelection.count > 1 { + newSelection.remove(option) + } + } else { + newSelection.insert(option) + } + selection = newSelection + } + }) { + HStack(spacing: 6) { + if let icon = icon { + Image(systemName: icon(option)) + .font(.system(size: 11, weight: .medium)) + } + Text(displayName(option)) + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(selection.contains(option) ? Color.primary : (hoveredItem == option ? Color.primary.opacity(0.7) : Color.secondary)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + if selection.contains(option) { + Capsule() + .fill(Color.mainSurface) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + hoveredItem = hovering ? option : nil + } + } + } + } + .padding(3) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } } \ No newline at end of file diff --git a/petalia/main/Components.swift b/petalia/main/Components.swift new file mode 100644 index 0000000..8bdaf8d --- /dev/null +++ b/petalia/main/Components.swift @@ -0,0 +1,1307 @@ +// +// Components.swift +// moonleaf +// +// Copyright ยฉ 2026 naomisphere. All rights reserved. +// + +import SwiftUI +import ImageIO +import AVFoundation + +struct RippleButton: View { + var cornerRadius: CGFloat = 14 + let action: () -> Void + @ViewBuilder let label: () -> Label + + @AppStorage("enableRipple") private var enableRipple = false + @State private var rippleScale: CGFloat = 0 + @State private var rippleOpacity: Double = 0 + + var body: some View { + Button(action: action) { + ZStack { + label() + + if enableRipple { + Circle() + .fill(.white.opacity(0.25)) + .scaleEffect(rippleScale) + .opacity(rippleOpacity) + .frame(width: 80, height: 80) + .allowsHitTesting(false) + } + } + } + .buttonStyle(.plain) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .contentShape(Rectangle()) + .onTapGesture { if enableRipple { fire() } else { action() } } + } + + private func fire() { + rippleScale = 0 + rippleOpacity = 0.7 + withAnimation(.easeOut(duration: 0.45)) { + rippleScale = 2.2 + rippleOpacity = 0 + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { action() } + } +} + +struct FavoriteButton: View { + let isFavorite: Bool + let action: () -> Void + + @State private var isHovered = false + @State private var bounce = false + + var body: some View { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { bounce.toggle() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { bounce = false } + } + action() + }) { + Image(systemName: isFavorite ? "star.fill" : "star") + .font(Font(font_loader.bold(size: 14))) + .foregroundStyle(isFavorite ? Color.accent : Color.white) + .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 1) + .scaleEffect(bounce ? 1.4 : (isHovered ? 1.15 : 1.0)) + .frame(width: 32, height: 32) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.white.opacity(0.15), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .onHover { hovering in withAnimation(.easeOut(duration: 0.15)) { isHovered = hovering } } + } +} + +struct DownloadedCheckButton: View { + @State private var isHovered = false + + var body: some View { + Button(action: {}) { + Image(systemName: "checkmark") + .font(Font(font_loader.bold(size: 13))) + .foregroundStyle(Color.accent) + .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 1) + .scaleEffect(isHovered ? 1.15 : 1.0) + .frame(width: 32, height: 32) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.white.opacity(0.15), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isHovered = hovering + } + } + } +} + +struct CardMenuButton: View { + let isStillWallpaper: Bool + @Binding var isMenuOpen: Bool + let onRename: () -> Void + let onExportOriginal: () -> Void + let onExportCustom: () -> Void + let onDelete: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: { isMenuOpen.toggle() }) { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.white.opacity(0.15), lineWidth: 0.5) + } + } + .scaleEffect(isHovered ? 1.15 : 1.0) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isHovered = hovering + } + } + .popover(isPresented: $isMenuOpen, arrowEdge: .bottom) { + CardMenuContent( + isStillWallpaper: isStillWallpaper, + showMenu: $isMenuOpen, + onRename: onRename, + onExportOriginal: onExportOriginal, + onExportCustom: onExportCustom, + onDelete: onDelete + ) + } + } +} + +struct CardMenuContent: View { + let isStillWallpaper: Bool + @Binding var showMenu: Bool + let onRename: () -> Void + let onExportOriginal: () -> Void + let onExportCustom: () -> Void + let onDelete: () -> Void + + @State private var showExportSubmenu = false + + var body: some View { + VStack(spacing: 4) { + if showExportSubmenu { + Button(action: { withAnimation(.easeInOut(duration: 0.15)) { showExportSubmenu = false } }) { + HStack(spacing: 8) { + Image(systemName: "chevron.left") + .font(.system(size: 10, weight: .bold)) + Text("Back") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.primary.opacity(0.08))) + + Divider().padding(.horizontal, 4) + + Button(action: { showMenu = false; onExportOriginal() }) { + HStack(spacing: 8) { + Image(systemName: "doc") + .font(.system(size: 12)) + Text("Original Size") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.primary.opacity(0.08))) + + Button(action: { showMenu = false; onExportCustom() }) { + HStack(spacing: 8) { + Image(systemName: "crop") + .font(.system(size: 12)) + Text("Custom Ratio") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.primary.opacity(0.08))) + + } else { + Button(action: { showMenu = false; onRename() }) { + HStack(spacing: 8) { + Image(systemName: "pencil") + .font(.system(size: 12)) + Text("Rename") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.primary.opacity(0.08))) + + if isStillWallpaper { + Button(action: { withAnimation(.easeInOut(duration: 0.15)) { showExportSubmenu = true } }) { + HStack(spacing: 8) { + Image(systemName: "tray.and.arrow.down") + .font(.system(size: 12)) + Text("Export") + .font(.system(size: 13, weight: .medium)) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.primary.opacity(0.08))) + } else { + Button(action: { showMenu = false; onExportOriginal() }) { + HStack(spacing: 8) { + Image(systemName: "tray.and.arrow.down") + .font(.system(size: 12)) + Text("Export Original") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.primary.opacity(0.08))) + } + + Divider().padding(.horizontal, 4) + + Button(action: { showMenu = false; onDelete() }) { + HStack(spacing: 8) { + Image(systemName: "trash") + .font(.system(size: 12)) + Text("Delete") + .font(.system(size: 13, weight: .medium)) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(MenuButtonStyle(hoverColor: Color.red, hoverTextColor: .white, defaultTextColor: .red)) + } + } + .padding(6) + .frame(width: 160) + } +} + +struct MenuButtonStyle: ButtonStyle { + var hoverColor: Color + var hoverTextColor: Color = .primary + var defaultTextColor: Color = .primary + + func makeBody(configuration: Configuration) -> some View { + MenuButtonView( + configuration: configuration, + hoverColor: hoverColor, + hoverTextColor: hoverTextColor, + defaultTextColor: defaultTextColor + ) + } + + private struct MenuButtonView: View { + let configuration: Configuration + let hoverColor: Color + let hoverTextColor: Color + let defaultTextColor: Color + + @State private var isHovered = false + + var body: some View { + configuration.label + .foregroundStyle(isHovered ? hoverTextColor : defaultTextColor) + .background { + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? hoverColor : Color.clear) + } + .onHover { hovering in + withAnimation(.easeOut(duration: 0.12)) { + isHovered = hovering + } + } + .opacity(configuration.isPressed ? 0.75 : 1.0) + } + } +} + +extension NSColor { + static var mainSurface: NSColor { + return NSColor(named: "main-surfece") ?? NSColor(srgbRed: 0.08, green: 0.10, blue: 0.18, alpha: 0.95) + } + static var secondarySurface: NSColor { + return NSColor(named: "Secondary-surfece") ?? NSColor(srgbRed: 0.14, green: 0.16, blue: 0.24, alpha: 0.8) + } + static var borderColor: NSColor { + return NSColor(named: "border-color") ?? NSColor.gray.withAlphaComponent(0.2) + } + static var content100: NSColor { + return NSColor(named: "content-100") ?? NSColor.labelColor + } + static var content200: NSColor { + return NSColor(named: "content-200") ?? NSColor.secondaryLabelColor + } + static var accent: NSColor { + return NSColor(named: "accent") ?? NSColor.systemBlue + } +} + +extension Color { + static let mainSurface = Color(NSColor.mainSurface) + static let secondarySurface = Color(NSColor.secondarySurface) + static let borderColor = Color(NSColor.borderColor) + static let content100 = Color(NSColor.content100) + static let content200 = Color(NSColor.content200) + static let accent = Color(NSColor.accent) +} + +// MARK: - VMasonry Layout (Layout protocol, macOS 13+) + +struct VMasonry: Layout { + var columns: Int + var horizontalSpacing: CGFloat + var verticalSpacing: CGFloat + + init(columns: Int = 3, spacing: CGFloat = 16) { + self.columns = max(1, columns) + self.horizontalSpacing = spacing + self.verticalSpacing = spacing + } + + init(columns: Int = 3, horizontalSpacing: CGFloat = 16, verticalSpacing: CGFloat = 16) { + self.columns = max(1, columns) + self.horizontalSpacing = horizontalSpacing + self.verticalSpacing = verticalSpacing + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? 800 + let colWidth = columnWidth(totalWidth: width) + var colHeights = Array(repeating: CGFloat(0), count: columns) + + for subview in subviews { + let colIndex = shortestColumn(colHeights) + let childSize = subview.sizeThatFits(ProposedViewSize(width: colWidth, height: nil)) + if colHeights[colIndex] > 0 { + colHeights[colIndex] += verticalSpacing + } + colHeights[colIndex] += childSize.height + } + + let maxHeight = colHeights.max() ?? 0 + return CGSize(width: width, height: maxHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let colWidth = columnWidth(totalWidth: bounds.width) + var colHeights = Array(repeating: CGFloat(0), count: columns) + + for subview in subviews { + let colIndex = shortestColumn(colHeights) + let x = bounds.minX + CGFloat(colIndex) * (colWidth + horizontalSpacing) + let y = bounds.minY + colHeights[colIndex] + + if colHeights[colIndex] > 0 { + colHeights[colIndex] += verticalSpacing + } + + let childSize = subview.sizeThatFits(ProposedViewSize(width: colWidth, height: nil)) + subview.place( + at: CGPoint(x: x, y: bounds.minY + colHeights[colIndex]), + proposal: ProposedViewSize(width: colWidth, height: childSize.height) + ) + colHeights[colIndex] += childSize.height + } + } + + private func columnWidth(totalWidth: CGFloat) -> CGFloat { + let totalSpacing = horizontalSpacing * CGFloat(columns - 1) + return max(0, (totalWidth - totalSpacing) / CGFloat(columns)) + } + + private func shortestColumn(_ heights: [CGFloat]) -> Int { + var minIndex = 0 + for i in 1.. Void + let onTap: () -> Void + let onDelete: () -> Void + let onRename: (String) -> Void + let onExport: (Bool) -> Void + let onQuickPreview: () -> Void + + @State private var isHovered = false + @State private var isEditing = false + @State private var showScreenPicker = false + @EnvironmentObject private var service: macpaperService + @State private var aspect: CGFloat = 1.5 + @State private var isMenuOpen = false + @State private var isHoveredSet = false + + private static var aspectCache = [String: CGFloat]() + + private var isStillWallpaper: Bool { + let ext = (wallpaper.path as NSString).pathExtension.lowercased() + return ["jpg", "jpeg", "png"].contains(ext) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + previewSection + .frame(maxWidth: .infinity) + WallpaperCardInfo(wallpaper: wallpaper, isEditing: $isEditing, onRename: onRename) + .padding(.bottom, 6) + } + .contentShape(Rectangle()) + .onTapGesture { + if service.isSelectionMode { + if service.selectedWallpapers.contains(wallpaper.id) { + service.selectedWallpapers.remove(wallpaper.id) + } else { + service.selectedWallpapers.insert(wallpaper.id) + } + } else { + onTap() + } + } + .onAppear { + service.refreshScreenCount() + loadAspectRatio() + } + .onChange(of: wallpaper.path) { _ in + loadAspectRatio() + } + } + + private var previewSection: some View { + ZStack { + Rectangle() + .fill(Color.brown.opacity(0.2)) + + let ext = (wallpaper.path as NSString).pathExtension.lowercased() + + if wallpaper.isFolder { + VStack(spacing: 12) { + Image(systemName: "folder.fill") + .font(.system(size: 48)) + .foregroundStyle(.secondary.opacity(0.8)) + } + } else if ["gif", "jpg", "jpeg", "png"].contains(ext) { + LazyImagePreview(path: wallpaper.path) + } else if ["mp4", "mov"].contains(ext) { + videoPreview(videoURL: URL(fileURLWithPath: wallpaper.path)) + .clipped() + .overlay { + Image(systemName: "play.circle.fill") + .font(.system(size: 32, weight: .light)) + .foregroundStyle(.white.opacity(0.8)) + .background { + Circle() + .fill(.black.opacity(0.3)) + .frame(width: 40, height: 40) + } + } + } else { + Image(systemName: "photo") + .font(.system(size: 24, weight: .light)) + .foregroundStyle(.secondary) + } + + if !service.isSelectionMode && (isHovered || isActive || isMenuOpen) { + overlayControls + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95)), + removal: .opacity.combined(with: .scale(scale: 1.05)) + )) + } + } + .aspectRatio(aspect, contentMode: .fill) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke( + (service.isSelectionMode ? (cardIsSelected ? Color.accent : Color.primary.opacity(0.1)) : + (isActive ? Color.accent : (cardIsSelected ? Color.accent : Color.primary.opacity(0.1)))), + style: StrokeStyle( + lineWidth: (service.isSelectionMode ? (cardIsSelected ? 3 : 1) : (isActive ? 3 : (cardIsSelected ? 3 : 1))), + lineCap: .round, + dash: (!service.isSelectionMode && isActive) ? [6, 4] : [] + ) + ) + .animation(.easeInOut(duration: 0.25), value: cardIsSelected) + .animation(.easeInOut(duration: 0.25), value: isActive) + } + .scaleEffect(isHovered ? 1.01 : 1.0) + .onHover { hovering in + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isHovered = hovering + } + } + } + + private var overlayControls: some View { + ZStack(alignment: .top) { + LinearGradient( + colors: [ + .black.opacity(0.55), + .black.opacity(0.15), + .clear + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 70) + .allowsHitTesting(false) + + VStack { + HStack { + FavoriteButton( + isFavorite: service.isFavorite(wallpaper), + action: { service.toggleFavorite(wallpaper) } + ) + + Spacer() + + if isActive && !isStillWallpaper { + VolumeSlider( + volume: $service.volume, + onVolumeChange: { newVolume in + service.chvol(newVolume) + } + ) + .scaleEffect(0.85) + .transition(.asymmetric( + insertion: .scale(scale: 0.8).combined(with: .opacity), + removal: .scale(scale: 0.8).combined(with: .opacity) + )) + } + + Spacer() + + CardMenuButton( + isStillWallpaper: isStillWallpaper, + isMenuOpen: $isMenuOpen, + onRename: { isEditing = true }, + onExportOriginal: { onExport(false) }, + onExportCustom: { onExport(true) }, + onDelete: onDelete + ) + } + .padding(.horizontal, 12) + .padding(.top, 12) + + Spacer() + + if !isActive { + if service.screenCount > 1 && !isStillWallpaper { + VStack(spacing: 6) { + if showScreenPicker { + HStack(spacing: 6) { + Button(action: { + showScreenPicker = false + onSelect() + }) { + VStack(spacing: 3) { + Image(systemName: "display.2") + .font(.system(size: 13, weight: .medium)) + Text("All") + .font(.system(size: 9, weight: .medium)) + } + .foregroundStyle(.white) + .frame(width: 44, height: 36) + .background { + RoundedRectangle(cornerRadius: 10) + .fill(Color(red: 0.42, green: 0.47, blue: 0.85).opacity(0.85)) + } + } + .buttonStyle(.plain) + + ForEach(0.. 0 { + resolvedAspect = width / height + } + } else if ["mp4", "mov"].contains(ext) { + let asset = AVURLAsset(url: URL(fileURLWithPath: path)) + if let track = asset.tracks(withMediaType: .video).first { + let size = track.naturalSize.applying(track.preferredTransform) + let width = abs(size.width) + let height = abs(size.height) + if height > 0 { + resolvedAspect = width / height + } + } + } + + if let aspectVal = resolvedAspect { + DispatchQueue.main.async { + Self.aspectCache[path] = aspectVal + withAnimation(.easeInOut(duration: 0.2)) { + self.aspect = aspectVal + } + } + } + } + } +} + +struct WallpaperCardInfo: View { + let wallpaper: endup_wp + @Binding var isEditing: Bool + let onRename: (String) -> Void + + @State private var editedName = "" + @FocusState private var isNameFocused: Bool + + var body: some View { + Group { + if isEditing { + HStack(spacing: 4) { + TextField("", text: $editedName) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundStyle(.primary.opacity(0.9)) + .focused($isNameFocused) + .onSubmit { + saveName() + } + + Button(action: saveName) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.green) + } + .buttonStyle(.plain) + + Button(action: { + isEditing = false + }) { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.red) + } + .buttonStyle(.plain) + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.primary.opacity(0.1)) + ) + .padding(.horizontal, 8) + } else { + HStack(alignment: .firstTextBaseline) { + Text(wallpaper.name) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundStyle(.primary.opacity(0.9)) + .lineLimit(2) + + Spacer() + + Text(ByteCountFormatter.string(fromByteCount: wallpaper.fileSize, countStyle: .file)) + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary.opacity(0.8)) + .layoutPriority(1) + } + .padding(.horizontal, 8) + } + } + .onChange(of: isEditing) { editing in + if editing { + editedName = wallpaper.name + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isNameFocused = true + } + } + } + } + + private func saveName() { + if !editedName.isEmpty && editedName != wallpaper.name { + onRename(editedName) + } + isEditing = false + } +} + +// MARK: - Browse Wallpaper Card + +struct BrowseWallpaperCard: View { + @EnvironmentObject private var service: macpaperService + let item: AnyWallpaper + + private var previewAspectRatio: CGFloat { + let rawRatio = CGFloat(item.width) / CGFloat(max(1, item.height)) + guard rawRatio.isFinite, rawRatio > 0 else { + return 16.0 / 9.0 + } + return min(max(rawRatio, 0.65), 2.4) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + BrowseCardPreview(item: item, aspectRatio: previewAspectRatio) + .frame(maxWidth: .infinity) + BrowseCardInfo(id: item.id, width: item.width, height: item.height) + .padding(.bottom, 6) + } + .contentShape(Rectangle()) + .onTapGesture { + let isDownloaded: Bool + let localPath: String + + if let downloadURL = item.downloadURL { + let home = FileManager.default.homeDirectoryForCurrentUser + let wpStorageDir = home.appendingPathComponent(".local/share/paper/wallpaper") + let fileName = "\(item.id)-\(downloadURL.lastPathComponent)" + let destinationURL = wpStorageDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + isDownloaded = true + localPath = destinationURL.path + } else { + isDownloaded = false + localPath = downloadURL.absoluteString + } + } else { + isDownloaded = false + localPath = item.previewURL?.absoluteString ?? "" + } + + let tempWp = endup_wp( + id: UUID(), + name: item.id, + path: localPath, + preview: item.previewURL?.absoluteString, + createdDate: Date(), + fileSize: 0 + ) + + withAnimation(.easeInOut(duration: 0.3)) { + service.previewWallpaper = tempWp + service.showPreview = true + } + } + } +} + +// MARK: - BrowseCardPreview + +private struct BrowseCardPreview: View { + let item: AnyWallpaper + let aspectRatio: CGFloat + + @State private var image: NSImage? + @State private var isLoading = true + @State private var isHovered = false + @State private var isDownloading = false + @State private var isDownloaded = false + @State private var downloadProgress: Double = 0 + @State private var downloadTask: URLSessionDownloadTask? + @State private var progressObservation: NSKeyValueObservation? + @State private var imageTask: URLSessionDataTask? + @State private var isDownloadButtonHovered = false + + var body: some View { + // MARK: - Preview + ZStack { + Rectangle() + .fill(Color.brown.opacity(0.2)) + .overlay { + if let image = image { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else if isLoading { + ProgressView() + } + } + .clipped() + + if item.isVideo { + Image(systemName: "play.circle.fill") + .font(.system(size: 32, weight: .light)) + .foregroundStyle(.white.opacity(0.8)) + .background { + Circle() + .fill(.black.opacity(0.3)) + .frame(width: 40, height: 40) + } + } + + if isHovered { + browseOverlay + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95)), + removal: .opacity.combined(with: .scale(scale: 1.05)) + )) + } + + if isDownloading { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 3) + Circle() + .trim(from: 0, to: max(0.05, downloadProgress)) + .stroke(Color.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 32, height: 32) + .background(Circle().fill(Color.black.opacity(0.4))) + } + } + .aspectRatio(aspectRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(Color.primary.opacity(0.1), lineWidth: 1) + } + .scaleEffect(isHovered ? 1.01 : 1.0) + .onHover { hovering in + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isHovered = hovering + } + } + .onAppear { + loadPreview() + } + .task { + checkIfDownloaded() + } + .onDisappear { + downloadTask?.cancel() + progressObservation?.invalidate() + imageTask?.cancel() + } + } + + // MARK: - Overlay + + private var browseOverlay: some View { + ZStack(alignment: .top) { + LinearGradient( + colors: [ + .black.opacity(0.55), + .black.opacity(0.15), + .clear + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 70) + .allowsHitTesting(false) + + VStack { + HStack(alignment: .center, spacing: 8) { + if item.provider == .pexels, let author = item.authorName, let url = item.authorURL { + Button(action: { NSWorkspace.shared.open(url) }) { + Text("by \(author)") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Capsule().fill(.black.opacity(0.3))) + } + .buttonStyle(.plain) + } + + Spacer() + + if isDownloaded { + DownloadedCheckButton() + } + } + .padding(.horizontal, 12) + .padding(.top, 12) + + Spacer() + + if !isDownloaded && !isDownloading { + Button(action: { + downloadWallpaper() + }) { + HStack(spacing: 6) { + Image(systemName: "arrow.down") + .font(.system(size: 13, weight: .medium)) + Text("Download") + .font(.system(size: 13, weight: .medium, design: .rounded)) + } + .foregroundStyle(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background { + RoundedRectangle(cornerRadius: 16) + .fill(.thinMaterial) + .overlay { + RoundedRectangle(cornerRadius: 16) + .stroke(.white.opacity(0.15), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + } + .scaleEffect(isDownloadButtonHovered ? 1.02 : 1.0) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isDownloadButtonHovered = hovering + } + } + .padding(.bottom, 12) + } + } + } + } + + // MARK: - Logic + + private func checkIfDownloaded() { + guard let downloadURL = item.downloadURL else { return } + let home = FileManager.default.homeDirectoryForCurrentUser + let wpStorageDir = home.appendingPathComponent(".local/share/paper/wallpaper") + let fileName = "\(item.id)-\(downloadURL.lastPathComponent)" + let destinationURL = wpStorageDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + isDownloaded = true + } + } + + private func loadPreview() { + guard let previewURL = item.previewURL else { + isLoading = false + return + } + + let cacheKey = previewURL.absoluteString as NSString + if let cachedImage = ThumbnailCache.shared.getImage(forKey: cacheKey as String) { + self.image = cachedImage + self.isLoading = false + return + } + + imageTask = URLSession.shared.dataTask(with: previewURL) { data, response, error in + if let _ = error { + DispatchQueue.main.async { self.isLoading = false } + return + } + + guard let data = data else { + DispatchQueue.main.async { self.isLoading = false } + return + } + + let nsImage = Self.downsampleImage(data: data, targetWidth: 400) ?? NSImage(data: data) + + guard let nsImage = nsImage else { + DispatchQueue.main.async { self.isLoading = false } + return + } + + ThumbnailCache.shared.setImage(nsImage, forKey: cacheKey as String) + + DispatchQueue.main.async { + self.image = nsImage + self.isLoading = false + } + } + imageTask?.resume() + } + + private func downloadWallpaper(retryCount: Int = 0) { + guard let downloadURL = item.downloadURL else { return } + + let home = FileManager.default.homeDirectoryForCurrentUser + let wpStorageDir = home.appendingPathComponent(".local/share/paper/wallpaper") + + do { + try FileManager.default.createDirectory(at: wpStorageDir, withIntermediateDirectories: true) + + let fileName = "\(item.id)-\(downloadURL.lastPathComponent)" + let destinationURL = wpStorageDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + isDownloaded = true + return + } + + isDownloading = true + downloadProgress = 0 + + downloadTask = URLSession.shared.downloadTask(with: downloadURL) { tempURL, response, error in + DispatchQueue.main.async { + self.isDownloading = false + self.progressObservation?.invalidate() + self.progressObservation = nil + } + + if let _ = error { + if retryCount < 2 { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.downloadWallpaper(retryCount: retryCount + 1) + } + } + return + } + + guard let tempURL = tempURL else { return } + + do { + if FileManager.default.fileExists(atPath: destinationURL.path) { + try? FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: tempURL, to: destinationURL) + + DispatchQueue.main.async { + self.isDownloaded = true + NotificationCenter.default.post( + name: NSNotification.Name("WallpaperDownloadCompleted"), + object: nil + ) + } + } catch { } + } + + progressObservation = downloadTask?.progress.observe(\.fractionCompleted) { progress, _ in + DispatchQueue.main.async { + self.downloadProgress = progress.fractionCompleted + } + } + + downloadTask?.resume() + + } catch { + isDownloading = false + downloadProgress = 0 + } + } + + // MARK: - Image Downsampling + + private static func downsampleImage(data: Data, targetWidth: CGFloat) -> NSImage? { + let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false] + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary) else { + return nil + } + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + let maxPixel = targetWidth * scale + let downsampleOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixel, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { + return nil + } + return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height)) + } +} + +// MARK: - BrowseCardInfo + +private struct BrowseCardInfo: View { + let id: String + let width: Int + let height: Int + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(id) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundStyle(.primary.opacity(0.9)) + .lineLimit(1) + Spacer() + Text("\(width)ร—\(height)") + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary.opacity(0.8)) + .layoutPriority(1) + } + .padding(.horizontal, 8) + } +} + +struct AccentButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + AccentButtonView(configuration: configuration) + } + + private struct AccentButtonView: View { + let configuration: Configuration + @State private var isHovered = false + + var body: some View { + configuration.label + .foregroundStyle(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background { + RoundedRectangle(cornerRadius: 200) + .fill(Color.accent) + .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) + } + .overlay { + RoundedRectangle(cornerRadius: 200) + .stroke( + LinearGradient( + colors: [.white.opacity(0.35), .clear], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 2 + ) + } + .opacity(configuration.isPressed ? 0.85 : (isHovered ? 0.92 : 1.0)) + .scaleEffect(configuration.isPressed ? 0.98 : (isHovered ? 1.02 : 1.0)) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isHovered = hovering + } + } + } + } +} + +struct MaterialButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + MaterialButtonView(configuration: configuration) + } + + private struct MaterialButtonView: View { + let configuration: Configuration + @State private var isHovered = false + + var body: some View { + configuration.label + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background { + Capsule() + .fill(.black.opacity(0.2)) + .overlay { + Capsule() + .stroke(.white.opacity(0.1), lineWidth: 0.5) + } + } + .opacity(configuration.isPressed ? 0.85 : (isHovered ? 0.92 : 1.0)) + .scaleEffect(configuration.isPressed ? 0.98 : (isHovered ? 1.02 : 1.0)) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isHovered = hovering + } + } + } + } +} diff --git a/petalia/main/ContentView.swift b/petalia/main/ContentView.swift new file mode 100644 index 0000000..d116ca2 --- /dev/null +++ b/petalia/main/ContentView.swift @@ -0,0 +1,311 @@ +// +// ContentView.swift +// moonleaf +// +// Copyright ยฉ 2026 naomisphere. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + @StateObject private var service = macpaperService() + @State private var selectedTab: TabSelection = .wallpapers + @AppStorage("glassBackground") private var glassBackground = false + @State private var showSortDropdown = false + + enum TabSelection: CaseIterable { + case wallpapers + case browse + + var title: String { + switch self { + case .wallpapers: return NSLocalizedString("mgr_library_title", value: "Library", comment: "library") + case .browse: return NSLocalizedString("mgr_browse_title", comment: "browse") + } + } + + var icon: String { + switch self { + case .wallpapers: return "photo.on.rectangle" + case .browse: return "globe" + } + } + } + + @Namespace private var tabAnimation + + var body: some View { + ZStack { + ZStack(alignment: .top) { + // Main content + ZStack { + Group { + switch selectedTab { + case .wallpapers: + ManagerView() + .environmentObject(service) + case .browse: + BrowseView() + .environmentObject(service) + } + } + .transition(.opacity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Top Bar + ZStack { + // Center: Tab Switcher + HStack(spacing: 8) { + Spacer() + + // Spacer of same width (32 button width + 8 spacing) to offset settings button and keep tabs centered + Spacer().frame(width: 40) + + HStack(spacing: 2) { + ForEach(TabSelection.allCases, id: \.self) { tab in + TabButton( + title: tab.title, + icon: tab.icon, + isSelected: selectedTab == tab, + namespace: tabAnimation, + action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { selectedTab = tab } + } + ) + } + } + .padding(3) + .background { + Capsule() + .fill(glassBackground ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(.regularMaterial)) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + + // Settings Button + Button(action: { show_settings() }) { + Image(systemName: "gearshape") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + + Spacer() + } + + // Right: Top-right actions stack + HStack { + Spacer() + if selectedTab == .wallpapers { + HStack(spacing: 12) { + // Kofi Button + Button(action: { + if let url = URL(string: "https://ko-fi.com/naomisphere") { + NSWorkspace.shared.open(url) + } + }) { + if let kofi_cup = NSImage(named: ".kofi") { + Image(nsImage: kofi_cup) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + } + .buttonStyle(.plain) + + // Favorite Toggle Button + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + service.showFavoritesOnly.toggle() + } + }) { + Image(systemName: service.showFavoritesOnly ? "star.fill" : "star") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(service.showFavoritesOnly ? Color.accent : Color.primary) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + + // Filters / Sort Button (arrow.up.arrow.down) + Button(action: { showSortDropdown.toggle() }) { + Image(systemName: "arrow.up.arrow.down") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .popover(isPresented: $showSortDropdown, arrowEdge: .bottom) { + VStack(spacing: 4) { + ForEach(macpaperService.LocalSortMode.allCases, id: \.self) { mode in + Button(action: { service.setLocalSort(mode); showSortDropdown = false }) { + HStack { + Text(mode.displayName) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(service.localSort == mode ? .primary : .secondary) + Spacer() + if service.localSort == mode { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(service.localSort == mode ? 0.08 : 0.001))) + } + .buttonStyle(.plain) + } + } + .padding(8) + .frame(width: 160) + } + } + .padding(.trailing, 24) + } else if selectedTab == .browse { + BrowseTopActionsView() + .padding(.trailing, 24) + } + } + } + .padding(.top, 12) + .padding(.bottom, 12) + .background { + VStack(spacing: 0) { + VariableBlurView(maxBlurRadius: 20, direction: .blurredTopClearBottom) + .frame(height: 72) + Spacer() + } + .ignoresSafeArea() + } + } + .focusable(false) + + if service.showPreview, let wallpaper = service.previewWallpaper { + QuickPreviewOverlay( + wallpaper: wallpaper, + onClose: { + withAnimation(.easeInOut(duration: 0.3)) { + service.showPreview = false + service.previewWallpaper = nil + } + } + ) + .environmentObject(service) + .transition(.opacity) + .zIndex(999) + } + } + } + + private func show_settings() { + let settingsView = SettingsView() + .environmentObject(service) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 500), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + + if let managerWindow = NSApp.keyWindow { + let managerFrame = managerWindow.frame + let settingsSize = window.frame.size + + let x = managerFrame.midX - settingsSize.width / 2 + let y = managerFrame.midY - settingsSize.height / 2 + + window.setFrameOrigin(NSPoint(x: x, y: y)) + } else { + window.center() + } + + window.title = "Settings" + window.titleVisibility = .hidden + window.contentView = NSHostingView(rootView: settingsView) + window.isReleasedWhenClosed = false + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +struct TabButton: View { + let title: String + let icon: String + let isSelected: Bool + let namespace: Namespace.ID + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 11, weight: .medium)) + Text(title) + .font(.system(size: 12, weight: .medium)) + } + .foregroundStyle(isSelected ? Color.primary : (isHovered ? Color.primary.opacity(0.7) : Color.secondary)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + if isSelected { + Capsule() + .fill(Color.mainSurface) + .overlay { + Capsule() + .stroke(.primary.opacity(0.1), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.06), radius: 3, x: 0, y: 1) + .matchedGeometryEffect(id: "activeTab", in: namespace) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { isHovered = hovering } + } + } +} + diff --git a/moonleaf/main/FontLoader.swift b/petalia/main/FontLoader.swift similarity index 100% rename from moonleaf/main/FontLoader.swift rename to petalia/main/FontLoader.swift diff --git a/moonleaf/main/ManagerApp.swift b/petalia/main/ManagerApp.swift similarity index 63% rename from moonleaf/main/ManagerApp.swift rename to petalia/main/ManagerApp.swift index a30d4d1..cd1e4ed 100644 --- a/moonleaf/main/ManagerApp.swift +++ b/petalia/main/ManagerApp.swift @@ -8,6 +8,7 @@ import SwiftUI import AVKit import UniformTypeIdentifiers +import ImageIO class MenuHandler: NSObject { weak var service: macpaperService? @@ -28,28 +29,122 @@ class MenuHandler: NSObject { } struct ManagerView: View { - @StateObject private var service = macpaperService() + @EnvironmentObject private var service: macpaperService @State private var show_importer = false @State private var importingFolder = false @State private var showAddPopover = false @State private var file_drag = false @State private var show_wp_util_overlay = false @State private var overlay_chosen_wp: endup_wp? = nil - @State private var showFavoritesOnly = false - @State private var showSortDropdown = false @State private var showWpActionsDropdown = false - @State private var previewWallpaper: endup_wp? = nil - @State private var showPreview = false + @State private var isSelectHovered = false private let menuHandler = MenuHandler() var body: some View { ZStack(alignment: .bottom) { - VStack(spacing: 0) { - toolbarView - contentView + GeometryReader { geo in + let width = geo.size.width + let spacing: CGFloat = 20 + let columnCount = max(2, Int((width - 48) / (260 + spacing))) + let colWidth = (width - 48 - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount) + let displayed = service.showFavoritesOnly + ? service.wallpapers.filter { service.isFavorite($0) } + : service.wallpapers + + ScrollView { + VStack(spacing: 0) { + Spacer().frame(height: 48) + + if service.isAtRoot { + toolbarView + } else { + HStack { + Button(action: { service.back() }) { + HStack(spacing: 8) { + Image(systemName: "arrow.left") + Text("Back") + } + .font(.system(size: 13, weight: .medium)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.1))) + } + .buttonStyle(.plain) + .padding(.leading, 32) + .padding(.top, 16) + + Spacer() + } + } + + ZStack { + if service.isLoading { + loadingView + .frame(minHeight: max(200, geo.size.height - 120)) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.9)), + removal: .opacity.combined(with: .scale(scale: 1.1)) + )) + } else if service.wallpapers.isEmpty { + NoWpView + .frame(minHeight: max(200, geo.size.height - 120)) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.95)), + removal: .opacity + )) + } else { + LazyLibraryMasonryContent( + wallpapers: displayed, + columnCount: columnCount, + spacing: spacing, + colWidth: colWidth, + onSelect: { wallpaper in + withAnimation(.easeInOut(duration: 0.3)) { service.set_wp(wallpaper) } + }, + onTap: { wallpaper in + if wallpaper.isFolder { + service.navigateTo(folder: wallpaper) + } else { + withAnimation(.easeInOut(duration: 0.3)) { + service.previewWallpaper = wallpaper + service.showPreview = true + } + } + }, + onDelete: { wallpaper in + withAnimation(.easeInOut(duration: 0.4)) { + delete_wp(wallpaper) + if service.selected_wp?.id == wallpaper.id { service.select_wp(nil) } + } + }, + onRename: { wallpaper, name in rename_wp(wallpaper, to: name) }, + onExport: { wallpaper, custom in export_wp(wallpaper, custom: custom) }, + onQuickPreview: { wallpaper in + withAnimation(.easeInOut(duration: 0.3)) { + service.previewWallpaper = wallpaper + service.showPreview = true + } + } + ) + .padding(.horizontal, 24) + .padding(.bottom, 32) + .padding(.top, 8) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity + )) + } + } + } + } + } + .onDrop(of: [.fileURL], isTargeted: $file_drag) { providers in + drop_handle(providers) } + .animation(.easeInOut(duration: 0.4), value: service.isLoading) + .animation(.easeInOut(duration: 0.4), value: service.wallpapers.isEmpty) - if show_wp_util_overlay, let wallpaper = overlay_chosen_wp { + if show_wp_util_overlay && !service.isSelectionMode, let wallpaper = overlay_chosen_wp { WPCUtilOverlay( wallpaper: wallpaper, onClose: { @@ -63,24 +158,69 @@ struct ManagerView: View { .transition(.opacity) .zIndex(1) } - - if showPreview, let wallpaper = previewWallpaper { - QuickPreviewOverlay( - wallpaper: wallpaper, - onClose: { - withAnimation(.easeInOut(duration: 0.3)) { - showPreview = false - previewWallpaper = nil + + if service.isSelectionMode { + VStack { + Spacer() + HStack(spacing: 16) { + Text("\(service.selectedWallpapers.count) items selected") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(.white.opacity(0.9)) + + Divider().frame(height: 24).background(Color.white.opacity(0.2)) + + // Favorite button + Button(action: { + toggleFavoriteForSelected() + }) { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.system(size: 11, weight: .semibold)) + Text("Favorite") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } + } + .buttonStyle(MaterialButtonStyle()) + .disabled(service.selectedWallpapers.isEmpty) + + Divider().frame(height: 24).background(Color.white.opacity(0.2)) + + // Delete button + Button(action: { + deleteSelected() + }) { + HStack(spacing: 6) { + Image(systemName: "trash.fill") + .font(.system(size: 11, weight: .semibold)) + Text("Delete") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } } + .buttonStyle(MaterialButtonStyle()) + .disabled(service.selectedWallpapers.isEmpty) } - ) - .transition(.opacity) - .zIndex(999) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.white.opacity(0.15), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + } + .padding(.bottom, 32) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(2) } } .onAppear { service.fetch_wallpapers() menuHandler.service = service + service.isSelectionMode = false + service.selectedWallpapers.removeAll() } .onChange(of: service.selected_wp) { selected in withAnimation(.easeInOut(duration: 0.3)) { @@ -110,28 +250,62 @@ struct ManagerView: View { } private var toolbarView: some View { - HStack(spacing: 12) { - Button(action: { showAddPopover.toggle() }) { - HStack(spacing: 8) { - Image(systemName: "plus") - .font(.system(size: 13, weight: .bold)) - Text("add wallpaper") - .font(Font(font_loader.bold(size: 13))) - Spacer() - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .medium)) - .opacity(0.8) + HStack { + Text("Library") + .font(.system(size: 48, weight: .bold)) + .foregroundStyle(.primary) + + Spacer() + + HStack(spacing: 12) { + // Select / Cancel button + Button(action: { + withAnimation(.easeInOut(duration: 0.2)) { + service.isSelectionMode.toggle() + if service.isSelectionMode { + service.select_wp(nil) + } else { + service.selectedWallpapers.removeAll() + } + } + }) { + Text(service.isSelectionMode ? "Cancel" : "Select") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(service.isSelectionMode ? Color.red : Color.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.primary.opacity(0.08), lineWidth: 0.5) + } + } } - .foregroundStyle(.white) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(width: 175) - .background { - RoundedRectangle(cornerRadius: 12) - .fill(LinearGradient(colors: [Color(red: 0.42, green: 0.47, blue: 0.85), Color(red: 0.32, green: 0.37, blue: 0.75)], startPoint: .topLeading, endPoint: .bottomTrailing)) + .buttonStyle(.plain) + .scaleEffect(isSelectHovered ? 1.02 : 1.0) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isSelectHovered = hovering + } + } + .help(service.isSelectionMode ? "Cancel Selection" : "Select Wallpapers") + + // Add button + Button(action: { showAddPopover.toggle() }) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 16, height: 16) + .padding(8) + .background { + Circle() + .fill(Color.accent) + } } + .buttonStyle(.plain) } - .buttonStyle(.plain) .popover(isPresented: $showAddPopover, arrowEdge: .bottom) { VStack(spacing: 4) { Button(action: { @@ -175,155 +349,11 @@ struct ManagerView: View { .padding(8) .frame(width: 160) } - - Button(action: { show_settings() }) { - HStack(spacing: 8) { - Image(systemName: "gearshape") - .font(.system(size: 13, weight: .medium)) - Text(NSLocalizedString("mgr_settings", comment: "settings")) - .font(.system(size: 13, weight: .medium)) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.primary.opacity(0.08))) - } - .buttonStyle(.plain) - - Spacer() - - if service.current_wp != nil { - Button(action: { showWpActionsDropdown.toggle() }) { - HStack(spacing: 6) { - Image(systemName: "slider.horizontal.3") - .font(.system(size: 13)) - Text("Active Wallpaper") - .font(.system(size: 13, weight: .semibold)) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.primary.opacity(showWpActionsDropdown ? 0.15 : 0.05))) - } - .buttonStyle(.plain) - .popover(isPresented: $showWpActionsDropdown, arrowEdge: .bottom) { - VStack(spacing: 4) { - if !isStillWallpaper(service.current_wp!) { - Button(action: { service.wp_doPersist(!service.wp_is_agent); showWpActionsDropdown = false }) { - HStack { - Image(systemName: service.wp_is_agent ? "checkmark.circle.fill" : "circle") - Text(NSLocalizedString("persist", comment: "")) - Spacer() - } - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.001))) - } - .buttonStyle(.plain) - } - Button(action: { service.unset_wp(); showWpActionsDropdown = false }) { - HStack { - Image(systemName: "xmark.circle") - .foregroundStyle(.red) - Text(NSLocalizedString("unset_current", comment: "")) - .foregroundStyle(.red) - Spacer() - } - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.red.opacity(0.05))) - } - .buttonStyle(.plain) - } - .padding(8) - .frame(width: 170) - } - } - - Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { showFavoritesOnly.toggle() } - }) { - HStack(spacing: 6) { - Image(systemName: showFavoritesOnly ? "star.fill" : "star") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(showFavoritesOnly ? .yellow : .secondary) - if showFavoritesOnly { - Text(NSLocalizedString("favorites_filter", comment: "")) - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.primary) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background { - RoundedRectangle(cornerRadius: 10) - .fill(.regularMaterial.opacity(showFavoritesOnly ? 0.9 : 0.5)) - .overlay { - RoundedRectangle(cornerRadius: 10) - .stroke(showFavoritesOnly ? Color.yellow.opacity(0.4) : Color.primary.opacity(0.15), lineWidth: 1) - } - } - } - .buttonStyle(.plain) - - Button(action: { showSortDropdown.toggle() }) { - HStack(spacing: 6) { - Text(service.localSort.displayName) - .font(.system(size: 13, weight: .semibold)) - Image(systemName: "chevron.down") - .font(.system(size: 10, weight: .semibold)) - } - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 12).fill(Color.primary.opacity(showSortDropdown ? 0.15 : 0.05))) - } - .buttonStyle(.plain) - .popover(isPresented: $showSortDropdown, arrowEdge: .bottom) { - VStack(spacing: 4) { - ForEach(macpaperService.LocalSortMode.allCases, id: \.self) { mode in - Button(action: { service.setLocalSort(mode); showSortDropdown = false }) { - HStack { - Text(mode.displayName) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(service.localSort == mode ? .primary : .secondary) - Spacer() - if service.localSort == mode { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .semibold)) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(service.localSort == mode ? 0.08 : 0.001))) - } - .buttonStyle(.plain) - } - } - .padding(8) - .frame(width: 160) - } - - Button(action: { service.fetch_wallpapers() }) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 14, weight: .medium)) - .padding(10) - .frame(height: 34) - .background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.08))) - } - .buttonStyle(.plain) } .padding(.horizontal, 32) - .padding(.vertical, 16) - .background { - RoundedRectangle(cornerRadius: 22) - .fill(.ultraThinMaterial.opacity(0.9)) - .overlay { - RoundedRectangle(cornerRadius: 22) - .stroke(.primary.opacity(0.1), lineWidth: 0.5) - } - } - .padding(.horizontal, 24) - .padding(.top, 16) + .padding(.top, 8) + .padding(.bottom, 8) + .frame(maxWidth: .infinity) } private func show_settings() { @@ -412,53 +442,7 @@ struct ManagerView: View { return nil } - private var contentView: some View { - VStack(spacing: 0) { - if !service.isAtRoot { - HStack { - Button(action: { service.back() }) { - HStack(spacing: 8) { - Image(systemName: "arrow.left") - Text("Back") - } - .font(.system(size: 13, weight: .medium)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.1))) - } - .buttonStyle(.plain) - .padding(.leading, 32) - .padding(.top, 16) - - Spacer() - } - } - - ZStack { - if service.isLoading { - loadingView - .transition(.asymmetric( - insertion: .opacity.combined(with: .scale(scale: 0.9)), - removal: .opacity.combined(with: .scale(scale: 1.1)) - )) - } else if service.wallpapers.isEmpty { - NoWpView - .transition(.asymmetric( - insertion: .opacity.combined(with: .scale(scale: 0.95)), - removal: .opacity - )) - } else { - gridView - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity - )) - } - } - } - .animation(.easeInOut(duration: 0.4), value: service.isLoading) - .animation(.easeInOut(duration: 0.4), value: service.wallpapers.isEmpty) - } + private var loadingView: some View { VStack(spacing: 20) { @@ -518,69 +502,7 @@ struct ManagerView: View { } } - private var gridView: some View { - ScrollView { - let columns = [ - GridItem(.fixed(300), spacing: 30), - GridItem(.fixed(300), spacing: 30), - GridItem(.fixed(300), spacing: 30) - ] - - LazyVGrid(columns: columns, alignment: .center, spacing: 30) { - let displayed = showFavoritesOnly - ? service.wallpapers.filter { service.isFavorite($0) } - : service.wallpapers - ForEach(Array(displayed.enumerated()), id: \.element.id) { index, wallpaper in - WallpaperCard( - wallpaper: wallpaper, - isActive: service.current_wp == wallpaper.path, - cardIsSelected: service.selected_wp?.id == wallpaper.id, - onSelect: { - withAnimation(.easeInOut(duration: 0.3)) { service.set_wp(wallpaper) } - }, - onTap: { - if wallpaper.isFolder { - service.navigateTo(folder: wallpaper) - } else { - withAnimation(.easeInOut(duration: 0.3)) { - if service.selected_wp?.id == wallpaper.id { - service.select_wp(nil) - } else { - service.select_wp(wallpaper) - } - } - } - }, - onDelete: { - withAnimation(.easeInOut(duration: 0.4)) { - delete_wp(wallpaper) - if service.selected_wp?.id == wallpaper.id { service.select_wp(nil) } - } - }, - onRename: { rename_wp(wallpaper, to: $0) }, - onExport: { export_wp(wallpaper) }, - onQuickPreview: { - withAnimation(.easeInOut(duration: 0.3)) { - previewWallpaper = wallpaper - showPreview = true - } - } - ) - .environmentObject(service) - .frame(width: 300, height: 260) - .transition(.asymmetric( - insertion: .opacity.combined(with: .scale(scale: 0.8).combined(with: .offset(y: 20))), - removal: .opacity.combined(with: .scale(scale: 0.8)) - )) - .animation(.easeOut(duration: 0.4).delay(Double(index) * 0.1), value: service.wallpapers.count) - } - } - .padding(32) - } - .onDrop(of: [.fileURL], isTargeted: $file_drag) { providers in - drop_handle(providers) - } - } + private func drop_handle(_ providers: [NSItemProvider]) -> Bool { for provider in providers { @@ -663,22 +585,56 @@ struct ManagerView: View { print("Error renaming file: \(error)") } } + + private func toggleFavoriteForSelected() { + let selectedWps = service.wallpapers.filter { service.selectedWallpapers.contains($0.id) } + let allAreFavorites = selectedWps.allSatisfy { service.isFavorite($0) } + + for wp in selectedWps { + let isFav = service.isFavorite(wp) + if allAreFavorites { + if isFav { + service.toggleFavorite(wp) + } + } else { + if !isFav { + service.toggleFavorite(wp) + } + } + } + + let appDelegate = NSApplication.shared.delegate as? AppDelegate + appDelegate?.update_sb_menu() + } + + private func deleteSelected() { + let selectedWps = service.wallpapers.filter { service.selectedWallpapers.contains($0.id) } + for wp in selectedWps { + do { + try FileManager.default.trashItem(at: URL(fileURLWithPath: wp.path), resultingItemURL: nil) + } catch { + print("Error trashing \(wp.name): \(error)") + } + } + service.selectedWallpapers.removeAll() + service.isSelectionMode = false + service.fetch_wallpapers() + + let appDelegate = NSApplication.shared.delegate as? AppDelegate + appDelegate?.update_sb_menu() + } - private func export_wp(_ wallpaper: endup_wp) { + private func export_wp(_ wallpaper: endup_wp, custom: Bool = false) { let sourceURL = URL(fileURLWithPath: wallpaper.path) guard FileManager.default.fileExists(atPath: sourceURL.path) else { return } let fileExtension = sourceURL.pathExtension.lowercased() let isImage = ["jpg", "jpeg", "png", "gif"].contains(fileExtension) - if isImage { - ExportManager.shared.showExportMenu( - for: wallpaper, - sourceURL: sourceURL, - showCropEditor: { image, wp, url in - self.showCropEditorWindow(image: image, wallpaper: wp, sourceURL: url) - } - ) + if isImage && custom { + if let image = NSImage(contentsOf: sourceURL) { + self.showCropEditorWindow(image: image, wallpaper: wallpaper, sourceURL: sourceURL) + } } else { ExportManager.shared.exportOriginal(wallpaper: wallpaper, sourceURL: sourceURL) } @@ -724,6 +680,63 @@ struct ManagerView: View { } } +private struct LazyLibraryMasonryContent: View { + let wallpapers: [endup_wp] + let columnCount: Int + let spacing: CGFloat + let colWidth: CGFloat + + let onSelect: (endup_wp) -> Void + let onTap: (endup_wp) -> Void + let onDelete: (endup_wp) -> Void + let onRename: (endup_wp, String) -> Void + let onExport: (endup_wp, Bool) -> Void + let onQuickPreview: (endup_wp) -> Void + + @EnvironmentObject private var service: macpaperService + + private var columns: [[endup_wp]] { + guard columnCount > 0 else { return [] } + var cols = Array(repeating: [endup_wp](), count: columnCount) + var heights = Array(repeating: CGFloat(0), count: columnCount) + + for item in wallpapers { + let shortest = heights.enumerated().min(by: { $0.element < $1.element })?.offset ?? 0 + cols[shortest].append(item) + let aspect: CGFloat = item.isFolder ? 1.0 : 1.5 + heights[shortest] += 1.0 / aspect + } + + return cols + } + + var body: some View { + HStack(alignment: .top, spacing: spacing) { + ForEach(0.. Void - let onTap: () -> Void - let onDelete: () -> Void - let onRename: (String) -> Void - let onExport: () -> Void - let onQuickPreview: () -> Void - - @State private var isHovered = false - @State private var isEditing = false - @State private var editedName = "" - @State private var showScreenPicker = false - @FocusState private var isNameFocused: Bool - @EnvironmentObject private var service: macpaperService - - private var isStillWallpaper: Bool { - let ext = (wallpaper.path as NSString).pathExtension.lowercased() - return ["jpg", "jpeg", "png"].contains(ext) - } - - var body: some View { - VStack(alignment: .center, spacing: 8) { - previewSection - .frame(maxWidth: .infinity) - .onTapGesture(count: 2) { - if !wallpaper.isFolder { - onQuickPreview() - } - } - infoSection - .frame(height: 50) - } - .frame(width: 300, height: 260) - .padding(12) - .background { - RoundedRectangle(cornerRadius: 22) - .fill(.regularMaterial.opacity(0.8)) - .overlay { - RoundedRectangle(cornerRadius: 22) - .stroke( - cardIsSelected ? Color.blue.opacity(0.6) : - isActive ? Color.primary.opacity(0.4) : Color.primary.opacity(0.1), - lineWidth: cardIsSelected ? 3 : (isActive ? 2 : 1) - ) - } - } - .scaleEffect(isHovered ? 1.02 : 1.0) - .onTapGesture { - onTap() - } - .onAppear { - service.refreshScreenCount() - } - .onChange(of: isEditing) { editing in - if editing { - editedName = wallpaper.name - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isNameFocused = true - } - } - } - .onHover { hovering in - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - isHovered = hovering - } - } - } - - private var previewSection: some View { - ZStack { - Rectangle() - .fill(Color.brown.opacity(0.2)) - - let ext = (wallpaper.path as NSString).pathExtension.lowercased() - - if wallpaper.isFolder { - VStack(spacing: 12) { - Image(systemName: "folder.fill") - .font(.system(size: 48)) - .foregroundStyle(.secondary.opacity(0.8)) - } - } else if ["gif", "jpg", "jpeg", "png"].contains(ext) { - LazyImagePreview(path: wallpaper.path) - } else if ["mp4", "mov"].contains(ext) { - videoPreview(videoURL: URL(fileURLWithPath: wallpaper.path)) - .clipped() - .overlay { - Image(systemName: "play.circle.fill") - .font(.system(size: 32, weight: .light)) - .foregroundStyle(.white.opacity(0.8)) - .background { - Circle() - .fill(.black.opacity(0.3)) - .frame(width: 40, height: 40) - } - } - } else { - Image(systemName: "photo") - .font(.system(size: 24, weight: .light)) - .foregroundStyle(.secondary) - } - - if isHovered || isActive { - overlayControls - .transition(.asymmetric( - insertion: .opacity.combined(with: .scale(scale: 0.9)), - removal: .opacity.combined(with: .scale(scale: 1.1)) - )) - } - } - .frame(width: 276, height: 184) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .overlay { - RoundedRectangle(cornerRadius: 16) - .stroke( - isActive - ? Color.primary.opacity(0.4) - : Color.primary.opacity(0.1), - lineWidth: isActive ? 2 : 1 - ) - } - .scaleEffect(isHovered ? 1.02 : 1.0) - .onHover { hovering in - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - isHovered = hovering - } - } - } - - private var overlayControls: some View { - ZStack { - LinearGradient( - colors: [ - .black.opacity(0.6), - .black.opacity(0.3), - .clear - ], - startPoint: .top, - endPoint: .bottom - ) - - VStack { - HStack { - if isActive { - Circle() - .fill(.green.opacity(0.9)) - .frame(width: 24, height: 24) - .overlay { - Image(systemName: "checkmark") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(.white) - } - .background { - Circle() - .fill(.regularMaterial) - .frame(width: 28, height: 28) - } - } - - FavoriteButton( - isFavorite: service.isFavorite(wallpaper), - action: { service.toggleFavorite(wallpaper) } - ) - - Spacer() - - if isActive && !isStillWallpaper { - VolumeSlider( - volume: $service.volume, - onVolumeChange: { newVolume in - service.chvol(newVolume) - } - ) - .scaleEffect(0.85) - .transition(.asymmetric( - insertion: .scale(scale: 0.8).combined(with: .opacity), - removal: .scale(scale: 0.8).combined(with: .opacity) - )) - } - - Spacer() - - HStack(spacing: 8) { - Button(action: { - isEditing = true - }) { - Circle() - .fill(.mint.opacity(0.9)) - .frame(width: 24, height: 24) - .overlay { - Image(systemName: "pencil") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - } - .background { - Circle() - .fill(.regularMaterial) - .frame(width: 28, height: 28) - } - } - .buttonStyle(.plain) - - Button(action: onExport) { - Circle() - .fill(.blue.opacity(0.9)) - .frame(width: 24, height: 24) - .overlay { - Image(systemName: "arrow.down.circle") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - } - .background { - Circle() - .fill(.regularMaterial) - .frame(width: 28, height: 28) - } - } - .buttonStyle(.plain) - - Button(action: onDelete) { - Circle() - .fill(.red.opacity(0.9)) - .frame(width: 24, height: 24) - .overlay { - Image(systemName: "trash") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - } - .background { - Circle() - .fill(.regularMaterial) - .frame(width: 28, height: 28) - } - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 12) - .padding(.top, 12) - - Spacer() - - if !isActive { - if service.screenCount > 1 && !isStillWallpaper { - VStack(spacing: 6) { - if showScreenPicker { - HStack(spacing: 6) { - Button(action: { - showScreenPicker = false - onSelect() - }) { - VStack(spacing: 3) { - Image(systemName: "display.2") - .font(.system(size: 13, weight: .medium)) - Text("All") - .font(.system(size: 9, weight: .medium)) - } - .foregroundStyle(.white) - .frame(width: 44, height: 36) - .background { - RoundedRectangle(cornerRadius: 10) - .fill(Color(red: 0.42, green: 0.47, blue: 0.85).opacity(0.85)) - } - } - .buttonStyle(.plain) - - ForEach(0.. NSImage { + let maxDimension: CGFloat = 300 + let origSize = fullImage.size + let ratio = origSize.width / origSize.height + let targetSize: NSSize + if ratio > 1 { + targetSize = NSSize(width: maxDimension, height: maxDimension / ratio) + } else { + targetSize = NSSize(width: maxDimension * ratio, height: maxDimension) + } + return self.resizeImageToFill(fullImage, to: targetSize) + } + private func resizeImageToFill(_ image: NSImage, to size: NSSize) -> NSImage { let imageSize = image.size let widthRatio = size.width / imageSize.width @@ -1631,7 +1265,7 @@ struct CropEditorView: View { .buttonStyle(.plain) } .padding() - .background(Color(NSColor.windowBackgroundColor)) + .background(Color.mainSurface) } private var cropCanvas: some View { @@ -1746,7 +1380,7 @@ struct CropEditorView: View { } } .padding() - .background(Color(NSColor.windowBackgroundColor)) + .background(Color.mainSurface) } private var ratioSidebar: some View { @@ -1777,7 +1411,7 @@ struct CropEditorView: View { .padding(.vertical, 8) } } - .background(Color(NSColor.controlBackgroundColor)) + .background(Color.secondarySurface) } private func sectionHeader(_ title: String) -> some View { @@ -2121,4 +1755,4 @@ class ExportManager: NSObject { alert.addButton(withTitle: "OK") alert.runModal() } -} \ No newline at end of file +} diff --git a/moonleaf/main/Settings.swift b/petalia/main/Settings.swift similarity index 77% rename from moonleaf/main/Settings.swift rename to petalia/main/Settings.swift index 668edd2..137aaac 100644 --- a/moonleaf/main/Settings.swift +++ b/petalia/main/Settings.swift @@ -1,12 +1,20 @@ // // Settings.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // import SwiftUI +@_silgen_name("system") +@discardableResult +private func c_system(_ command: UnsafePointer?) -> Int32 + +private func escapeShellArg(_ arg: String) -> String { + return "'" + arg.replacingOccurrences(of: "'", with: "'\\''") + "'" +} + struct SettingsView: View { @EnvironmentObject private var service: macpaperService @State private var selectedTab: SettingsTab = .general @@ -29,6 +37,7 @@ struct SettingsView: View { @AppStorage("exportFolderPath") private var exportFolderPath = "" @AppStorage("useAsScreensaver") private var useAsScreensaver = false @AppStorage("glassBackground") private var glassBackground = false + @AppStorage("appTheme") private var appTheme = "system" @State private var visualizer_mode: String = "disabled" @State private var visualizer_colorMode: String = "rainbow" @@ -70,7 +79,7 @@ struct SettingsView: View { HStack(spacing: 16) { Image(systemName: "gearshape.fill") .font(Font(font_loader.regular(size: 24))) - .foregroundStyle(.blue) + .foregroundStyle(Color.accent) Text(NSLocalizedString("settings", comment: "Settings")) .font(Font(font_loader.bold(size: 22))) @@ -96,9 +105,10 @@ struct SettingsView: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 8) - .fill(selectedTab == tab ? Color.blue : Color.clear) + .fill(selectedTab == tab ? Color.accent : Color.clear) ) .foregroundStyle(selectedTab == tab ? .white : .secondary) + .contentShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } @@ -122,7 +132,7 @@ struct SettingsView: View { } } .frame(minWidth: 550, idealWidth: 600, minHeight: 500, idealHeight: 550) - .background(Color(NSColor.windowBackgroundColor)) + .background(Color.mainSurface) .onAppear { loadAPIKey() ap_is_enabled = service.ap_is_enabled @@ -135,6 +145,11 @@ struct SettingsView: View { .sheet(isPresented: $showChangelogSheet) { changelogSheet } + .onChange(of: appTheme) { newValue in + if let delegate = NSApp.delegate as? AppDelegate { + delegate.updateAppearances(to: newValue) + } + } } private var changelogSheet: some View { @@ -311,11 +326,43 @@ struct SettingsView: View { private var managerSettings: some View { VStack(alignment: .leading, spacing: 20) { Section(title: NSLocalizedString("settings_appearance", comment: "Appearance")) { - SToggle( - title: NSLocalizedString("settings_glass_bg", comment: "Glass Background"), - description: NSLocalizedString("settings_glass_bg_desc", comment: ""), - isOn: $glassBackground - ) + VStack(alignment: .leading, spacing: 12) { + HStack { + Text(NSLocalizedString("settings_theme", value: "App Theme", comment: "App Theme")) + .font(Font(font_loader.regular(size: 14))) + Spacer() + SegmentSelector( + options: ["system", "light", "dark"], + selection: $appTheme, + displayName: { theme in + switch theme { + case "system": return NSLocalizedString("settings_theme_system", value: "System", comment: "System") + case "light": return NSLocalizedString("settings_theme_light", value: "Light", comment: "Light") + case "dark": return NSLocalizedString("settings_theme_dark", value: "Dark", comment: "Dark") + default: return theme + } + } + ) + } + .padding() + .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.05))) + + SToggle( + title: NSLocalizedString("settings_glass_bg", comment: "Glass Background"), + description: NSLocalizedString("settings_glass_bg_desc", comment: ""), + isOn: $glassBackground + ) + + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.system(size: 11)) + Text(NSLocalizedString("settings_appearance_restart_note", value: "Restart the app to apply theme or background changes.", comment: "")) + .font(Font(font_loader.regular(size: 11))) + } + .foregroundColor(.secondary) + .padding(.leading, 4) + .padding(.top, 2) + } } Section(title: NSLocalizedString("settings_sort", comment: "Default Sort")) { @@ -325,16 +372,14 @@ struct SettingsView: View { Spacer() - Picker("", selection: Binding( - get: { service.localSort }, - set: { service.setLocalSort($0) } - )) { - ForEach(macpaperService.LocalSortMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - .pickerStyle(MenuPickerStyle()) - .frame(width: 160) + SegmentSelector( + options: macpaperService.LocalSortMode.allCases, + selection: Binding( + get: { service.localSort }, + set: { service.setLocalSort($0) } + ), + displayName: { $0.displayName } + ) } .padding() .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.05))) @@ -378,16 +423,25 @@ struct SettingsView: View { Section(title: "Import Method") { VStack(alignment: .leading, spacing: 12) { - Text("Choose how wallpapers are added to your library.") - .font(Font(font_loader.regular(size: 12))) - .foregroundColor(.secondary) - - Picker("", selection: $service.importMethod) { - Text("Link/Reference").tag(macpaperService.ImportMethod.link) - Text("Copy").tag(macpaperService.ImportMethod.copy) + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Choose how wallpapers are added to your library.") + .font(Font(font_loader.regular(size: 12))) + .foregroundColor(.secondary) + } + Spacer() + SegmentSelector( + options: macpaperService.ImportMethod.allCases, + selection: $service.importMethod, + displayName: { method in + switch method { + case .link: return "Link/Reference" + case .copy: return "Copy" + } + } + ) + .onChange(of: service.importMethod) { _ in service.saveSettings() } } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: service.importMethod) { _ in service.saveSettings() } } .padding() .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.05))) @@ -425,16 +479,14 @@ struct SettingsView: View { Spacer() - Picker("", selection: Binding( - get: { service.shuffleInterval }, - set: { service.setShuffleInterval($0) } - )) { - ForEach(macpaperService.ShuffleInterval.allCases, id: \.self) { interval in - Text(interval.displayName).tag(interval) - } - } - .pickerStyle(MenuPickerStyle()) - .frame(width: 160) + SegmentSelector( + options: macpaperService.ShuffleInterval.allCases, + selection: Binding( + get: { service.shuffleInterval }, + set: { service.setShuffleInterval($0) } + ), + displayName: { $0.displayName } + ) } .padding(.horizontal, 16) .transition(.opacity.combined(with: .move(edge: .top))) @@ -444,31 +496,48 @@ struct SettingsView: View { Section(title: "Playback Effects") { VStack(alignment: .leading, spacing: 12) { - Text("Wallpaper Scaling") - .font(Font(font_loader.regular(size: 14))) - - Picker("", selection: $scalingMode) { - Text("Fill Screen").tag("fill") - Text("Fit to Screen").tag("fit") - Text("Stretch to Fill").tag("stretch") - Text("Center").tag("center") - Text("Tile").tag("tile") + HStack { + Text("Wallpaper Scaling") + .font(Font(font_loader.regular(size: 14))) + Spacer() + SegmentSelector( + options: ["fill", "fit", "stretch", "center", "tile"], + selection: $scalingMode, + displayName: { mode in + switch mode { + case "fill": return "Fill Screen" + case "fit": return "Fit to Screen" + case "stretch": return "Stretch to Fill" + case "center": return "Center" + case "tile": return "Tile" + default: return mode + } + } + ) + .onChange(of: scalingMode) { _ in saveVisualizerSettings() } } - .pickerStyle(MenuPickerStyle()) - .onChange(of: scalingMode) { _ in saveVisualizerSettings() } - Text("Video Filter") - .font(Font(font_loader.regular(size: 14))) - .padding(.top, 8) + Divider().padding(.vertical, 4) - Picker("", selection: $videoFilter) { - Text("None").tag("none") - Text("Grayscale").tag("grayscale") - Text("Invert").tag("invert") - Text("Sepia").tag("sepia") + HStack { + Text("Video Filter") + .font(Font(font_loader.regular(size: 14))) + Spacer() + SegmentSelector( + options: ["none", "grayscale", "invert", "sepia"], + selection: $videoFilter, + displayName: { filter in + switch filter { + case "none": return "None" + case "grayscale": return "Grayscale" + case "invert": return "Invert" + case "sepia": return "Sepia" + default: return filter + } + } + ) + .onChange(of: videoFilter) { _ in saveVisualizerSettings() } } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: videoFilter) { _ in saveVisualizerSettings() } } .padding() .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.05))) @@ -476,40 +545,63 @@ struct SettingsView: View { Section(title: "Audio Visualizer (beta)") { VStack(alignment: .leading, spacing: 12) { - Text("Visualizer Mode") - .font(Font(font_loader.regular(size: 14))) - - Picker("", selection: $visualizer_mode) { - Text("Disabled").tag("disabled") - Text("Wallpaper Audio").tag("wallpaper") + HStack { + Text("Visualizer Mode") + .font(Font(font_loader.regular(size: 14))) + Spacer() + SegmentSelector( + options: ["disabled", "wallpaper"], + selection: $visualizer_mode, + displayName: { mode in + switch mode { + case "disabled": return "Disabled" + case "wallpaper": return "Wallpaper Audio" + default: return mode + } + } + ) + .onChange(of: visualizer_mode) { _ in saveVisualizerSettings() } } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: visualizer_mode) { _ in saveVisualizerSettings() } - Text("Color Mode") - .font(Font(font_loader.regular(size: 14))) - .padding(.top, 8) + Divider().padding(.vertical, 4) - Picker("", selection: $visualizer_colorMode) { - Text("Rainbow").tag("rainbow") - Text("Custom").tag("custom") + HStack { + Text("Color Mode") + .font(Font(font_loader.regular(size: 14))) + Spacer() + SegmentSelector( + options: ["rainbow", "custom"], + selection: $visualizer_colorMode, + displayName: { mode in + switch mode { + case "rainbow": return "Rainbow" + case "custom": return "Custom" + default: return mode + } + } + ) + .onChange(of: visualizer_colorMode) { _ in saveVisualizerSettings() } } - .pickerStyle(SegmentedPickerStyle()) - .onChange(of: visualizer_colorMode) { _ in saveVisualizerSettings() } if visualizer_colorMode == "custom" { + Divider().padding(.vertical, 4) HStack { Text("Color:") + .font(Font(font_loader.regular(size: 14))) + Spacer() TextField("#FF00FF", text: $visualizer_customColor) .textFieldStyle(RoundedBorderTextFieldStyle()) .frame(width: 100) .onChange(of: visualizer_customColor) { _ in saveVisualizerSettings() } } - .padding(.top, 4) } + Divider().padding(.vertical, 4) + HStack { Text("Transparency:") + .font(Font(font_loader.regular(size: 14))) + Spacer() Slider(value: $visualizer_transparency, in: 0.1...1.0, step: 0.05) .frame(width: 150) .onChange(of: visualizer_transparency) { _ in saveVisualizerSettings() } @@ -517,26 +609,27 @@ struct SettingsView: View { .font(Font(font_loader.regular(size: 12))) .frame(width: 35) } - .padding(.top, 8) + + Divider().padding(.vertical, 4) HStack { Text("Bar Count:") - Picker("", selection: $visualizer_barCount) { - Text("32").tag(32) - Text("48").tag(48) - Text("64").tag(64) - Text("80").tag(80) - Text("96").tag(96) - } - .pickerStyle(MenuPickerStyle()) - .frame(width: 80) - .onChange(of: visualizer_barCount) { _ in saveVisualizerSettings() } + .font(Font(font_loader.regular(size: 14))) Spacer() + SegmentSelector( + options: [32, 48, 64, 80, 96], + selection: $visualizer_barCount, + displayName: { "\($0)" } + ) + .onChange(of: visualizer_barCount) { _ in saveVisualizerSettings() } } - .padding(.top, 8) + + Divider().padding(.vertical, 4) HStack { Text("Max Height:") + .font(Font(font_loader.regular(size: 14))) + Spacer() Slider(value: $visualizer_maxHeight, in: 0.1...1.0, step: 0.05) .frame(width: 150) .onChange(of: visualizer_maxHeight) { _ in saveVisualizerSettings() } @@ -544,10 +637,13 @@ struct SettingsView: View { .font(Font(font_loader.regular(size: 12))) .frame(width: 35) } - .padding(.top, 4) + + Divider().padding(.vertical, 4) HStack { Text("Min Height:") + .font(Font(font_loader.regular(size: 14))) + Spacer() Slider(value: $visualizer_minHeight, in: 1.0...20.0, step: 1.0) .frame(width: 150) .onChange(of: visualizer_minHeight) { _ in saveVisualizerSettings() } @@ -555,7 +651,6 @@ struct SettingsView: View { .font(Font(font_loader.regular(size: 12))) .frame(width: 35) } - .padding(.top, 4) } .padding() .background(RoundedRectangle(cornerRadius: 8).fill(Color.primary.opacity(0.05))) @@ -591,7 +686,7 @@ struct SettingsView: View { private func loadVisualizerSettings() { let settingsFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/settings.json") + .appendingPathComponent(".config/petalia/settings.json") guard FileManager.default.fileExists(atPath: settingsFile.path), let data = try? Data(contentsOf: settingsFile), @@ -610,7 +705,7 @@ struct SettingsView: View { private func saveVisualizerSettings() { let settingsFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/settings.json") + .appendingPathComponent(".config/petalia/settings.json") var settings: [String: Any] = [:] if FileManager.default.fileExists(atPath: settingsFile.path), @@ -636,7 +731,7 @@ struct SettingsView: View { try? data.write(to: settingsFile) DistributedNotificationCenter.default().postNotificationName( - Notification.Name("com.naomisphere.moonleaf.visualizerSettingsChanged"), + Notification.Name("com.naomisphere.petalia.visualizerSettingsChanged"), object: nil, deliverImmediately: true ) @@ -752,18 +847,13 @@ struct SettingsView: View { at: launchAgent.deletingLastPathComponent(), withIntermediateDirectories: true) try plist.write(to: launchAgent, atomically: true, encoding: .utf8) - let load = Process() - load.launchPath = "/bin/launchctl" - load.arguments = ["load", launchAgent.path] - load.launch() - load.waitUntilExit() + let status = c_system("launchctl load \(escapeShellArg(launchAgent.path))") + if (status >> 8) != 0 { + autoStartEnabled = false + } } catch { autoStartEnabled = false } } else { - let unload = Process() - unload.launchPath = "/bin/launchctl" - unload.arguments = ["unload", launchAgent.path] - try? unload.run() - unload.waitUntilExit() + c_system("launchctl unload \(escapeShellArg(launchAgent.path)) > /dev/null 2>&1") try? FileManager.default.removeItem(at: launchAgent) } } @@ -893,7 +983,7 @@ struct SToggle: View { } Spacer() Toggle("", isOn: $isOn) - .toggleStyle(SwitchToggleStyle(tint: .blue)) + .toggleStyle(SwitchToggleStyle(tint: Color.accent)) .labelsHidden() } .padding() diff --git a/moonleaf/main/Updater.swift b/petalia/main/Updater.swift similarity index 92% rename from moonleaf/main/Updater.swift rename to petalia/main/Updater.swift index b55549c..98904df 100644 --- a/moonleaf/main/Updater.swift +++ b/petalia/main/Updater.swift @@ -1,6 +1,6 @@ // // Updater.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // @@ -19,7 +19,7 @@ class Updater: ObservableObject { private var baseUrl: String { let defaultUrl = "https://raw.githubusercontent.com/parkuoa/moonleaf/main" let serverFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/macpaper/update_server") + .appendingPathComponent(".local/share/petalia/update_server") if FileManager.default.fileExists(atPath: serverFile.path), let server = try? String(contentsOf: serverFile) { @@ -35,7 +35,7 @@ class Updater: ObservableObject { private var apiBaseUrl: String { let defaultUrl = "https://api.github.com/repos/parkuoa/moonleaf" let serverFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/macpaper/update_server") + .appendingPathComponent(".local/share/petalia/update_server") if FileManager.default.fileExists(atPath: serverFile.path), let server = try? String(contentsOf: serverFile) { @@ -118,20 +118,23 @@ if [ "$LATEST" != "$CURRENT" ]; then C_TMPDIR="$2/.tmp" mkdir -p "$C_TMPDIR" - LATEST_URL="$SERVER_URL/releases/download/$LATEST/moonleaf.dmg" - DMG_PATH="$C_TMPDIR/moonleaf.dmg" + LATEST_URL="$SERVER_URL/releases/download/$LATEST/Petalia.dmg" + DMG_PATH="$C_TMPDIR/Petalia.dmg" curl -L -o "$DMG_PATH" "$LATEST_URL" - VOLUME_NAME="moonleaf" + VOLUME_NAME="Petalia" if [ -d "/Volumes/$VOLUME_NAME" ]; then hdiutil detach "/Volumes/$VOLUME_NAME" -force fi hdiutil attach "$DMG_PATH" - cp -rf "/Volumes/$VOLUME_NAME/moonleaf.app" "/Applications/" + cp -rf "/Volumes/$VOLUME_NAME/Petalia.app" "/Applications/" + if [ -d "/Applications/moonleaf.app" ]; then + rm -rf "/Applications/moonleaf.app" + fi if [ -d "/Applications/macpaper.app" ]; then rm -rf "/Applications/macpaper.app" fi @@ -160,10 +163,10 @@ exit 0 let resourcesPath = "\(appPath)/Contents/Resources" let tempDir = NSTemporaryDirectory() - let updaterScript = "\(tempDir)moonleaf_updater_\(UUID().uuidString).sh" + let updaterScript = "\(tempDir)petalia_updater_\(UUID().uuidString).sh" let serverFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".local/share/macpaper/update_server") + .appendingPathComponent(".local/share/petalia/update_server") var serverArg = "https://github.com/parkuoa/moonleaf" if FileManager.default.fileExists(atPath: serverFile.path), let server = try? String(contentsOf: serverFile) { @@ -213,7 +216,7 @@ exit 0 private func show_reopen_prompt() { let alert = NSAlert() alert.messageText = "Update Completed" - alert.informativeText = "Update installed successfully. Restart moonleaf to use the new version." + alert.informativeText = "Update installed successfully. Restart Petalia to use the new version." alert.alertStyle = .informational alert.addButton(withTitle: "Restart") alert.addButton(withTitle: "Later") diff --git a/moonleaf/main/macpaper.swift b/petalia/main/macpaper.swift similarity index 83% rename from moonleaf/main/macpaper.swift rename to petalia/main/macpaper.swift index d9849e7..78de6e0 100644 --- a/moonleaf/main/macpaper.swift +++ b/petalia/main/macpaper.swift @@ -1,6 +1,6 @@ // // macpaper.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // @@ -26,6 +26,7 @@ struct macpaper: App { class AppDelegate: NSObject, NSApplicationDelegate { var status_item: NSStatusItem? + var sb_menu: NSMenu? var _mwin: NSWindow? var _settingsWin: NSWindow? var _mwin_open = false @@ -40,6 +41,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { font_loader.regFonts() + let savedTheme = UserDefaults.standard.string(forKey: "appTheme") ?? "system" + updateAppearances(to: savedTheme) + performFirstLaunchMigration() make_paper_sb_item() start_launchAgent() @@ -65,28 +69,31 @@ class AppDelegate: NSObject, NSApplicationDelegate { let home = FileManager.default.homeDirectoryForCurrentUser let fm = FileManager.default + let petaliaConfig = home.appendingPathComponent(".config/petalia") let moonleafConfig = home.appendingPathComponent(".config/moonleaf") let oldConfig = home.appendingPathComponent(".local/share/macpaper") - try? fm.createDirectory(at: moonleafConfig, withIntermediateDirectories: true) - - let newSettings = moonleafConfig.appendingPathComponent("settings.json") - let oldSettings = oldConfig.appendingPathComponent("settings.json") - - if !fm.fileExists(atPath: newSettings.path) && fm.fileExists(atPath: oldSettings.path) { - let items = (try? fm.contentsOfDirectory(at: oldConfig, includingPropertiesForKeys: nil)) ?? [] - for item in items { - let dest = moonleafConfig.appendingPathComponent(item.lastPathComponent) - if !fm.fileExists(atPath: dest.path) { - try? fm.createSymbolicLink(at: dest, withDestinationURL: item) + if !fm.fileExists(atPath: petaliaConfig.path) { + if fm.fileExists(atPath: moonleafConfig.path) { + try? fm.copyItem(at: moonleafConfig, to: petaliaConfig) + } else if fm.fileExists(atPath: oldConfig.path) { + try? fm.createDirectory(at: petaliaConfig, withIntermediateDirectories: true) + let items = (try? fm.contentsOfDirectory(at: oldConfig, includingPropertiesForKeys: nil)) ?? [] + for item in items { + let dest = petaliaConfig.appendingPathComponent(item.lastPathComponent) + if !fm.fileExists(atPath: dest.path) { + try? fm.createSymbolicLink(at: dest, withDestinationURL: item) + } } } } + + try? fm.createDirectory(at: petaliaConfig, withIntermediateDirectories: true) } func start_launchAgent() { let home = FileManager.default.homeDirectoryForCurrentUser - let launchAgent = home.appendingPathComponent("Library/LaunchAgents/com.naomisphere.moonleaf.wallpaper.plist") + let launchAgent = home.appendingPathComponent("Library/LaunchAgents/com.naomisphere.petalia.wallpaper.plist") if FileManager.default.fileExists(atPath: launchAgent.path) { let service = macpaperService() @@ -102,17 +109,28 @@ class AppDelegate: NSObject, NSApplicationDelegate { status_item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = status_item?.button { - if let resourcePath = Bundle.main.path(forResource: "StatusBarIcon", ofType: "png"), - let iconImage = NSImage(contentsOfFile: resourcePath) { + if let iconImage = NSImage(named: "petalia") { + iconImage.size = NSSize(width: 18, height: 18) + iconImage.isTemplate = true + button.image = iconImage + } else if let resourcePath = Bundle.main.path(forResource: "StatusBarIcon", ofType: "png"), + let iconImage = NSImage(contentsOfFile: resourcePath) { iconImage.size = NSSize(width: 18, height: 18) iconImage.isTemplate = false button.image = iconImage } else { - button.image = NSImage(systemSymbolName: "drop.fill", accessibilityDescription: "moonleaf") + button.image = NSImage(systemSymbolName: "drop.fill", accessibilityDescription: "Petalia") } + button.target = self + button.action = #selector(statusBarButtonClicked(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) } - status_item?.menu = sb_item_menu() + update_sb_menu() + } + + func update_sb_menu() { + sb_menu = sb_item_menu() } func remove_sb_item() { @@ -122,6 +140,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { status_item = nil } + @objc func statusBarButtonClicked(_ sender: NSStatusBarButton) { + let event = NSApp.currentEvent + if event?.type == .rightMouseUp || event?.modifierFlags.contains(.control) == true { + if let menu = sb_menu { + menu.delegate = self + status_item?.menu = menu + status_item?.button?.performClick(nil) + } + } else { + show_manager() + } + } + @objc func show_manager() { if _mwin == nil { let contentView = ContentView() @@ -134,11 +165,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.isReleasedWhenClosed = false window.center() - window.title = "moonleaf" + window.title = "Petalia" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.hasShadow = true + let useGlass = glassBackground if useGlass { @@ -149,8 +181,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor } else { window.isOpaque = true - window.backgroundColor = NSColor(srgbRed: 0.08, green: 0.10, blue: 0.18, alpha: 0.95) - window.contentView = NSHostingView(rootView: contentView.font(Font(font_loader.regular(size: 13)))) + window.backgroundColor = .mainSurface + window.contentView = NSHostingView(rootView: contentView.font(Font(font_loader.regular(size: 13))).ignoresSafeArea()) } window.minSize = NSSize(width: 900, height: 600) @@ -282,12 +314,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { settingsItem.target = self menu.addItem(settingsItem) - let aboutItem = NSMenuItem(title: NSLocalizedString("sb_about", comment: "About moonleaf"), action: #selector(show_about), keyEquivalent: "") + let aboutItem = NSMenuItem(title: NSLocalizedString("sb_about", comment: "About Petalia"), action: #selector(show_about), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) menu.addItem(NSMenuItem.separator()) - let quitItem = NSMenuItem(title: NSLocalizedString("sb_quit", comment: "Quit moonleaf"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + let quitItem = NSMenuItem(title: NSLocalizedString("sb_quit", comment: "Quit Petalia"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") menu.addItem(quitItem) return menu @@ -308,7 +340,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let service = macpaperService() service.set_wp(wallpaper) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.status_item?.menu = self.sb_item_menu() + self.update_sb_menu() } } @@ -319,7 +351,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let service = macpaperService() service.wp_doPersist(!is_agent_enabled) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.status_item?.menu = self.sb_item_menu() + self.update_sb_menu() } } @@ -327,7 +359,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let service = macpaperService() service.unset_wp() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.status_item?.menu = self.sb_item_menu() + self.update_sb_menu() } } @@ -360,6 +392,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.titleVisibility = .hidden window.contentView = NSHostingView(rootView: settingsView.font(Font(font_loader.regular(size: 13)))) window.isReleasedWhenClosed = false + _settingsWin = window NSApp.setActivationPolicy(.regular) @@ -371,7 +404,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let service = macpaperService() service.fetch_wallpapers() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.status_item?.menu = self.sb_item_menu() + self.update_sb_menu() } } @@ -380,7 +413,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" let alert = NSAlert() - alert.messageText = "moonleaf" + alert.messageText = "Petalia" alert.informativeText = String(format: NSLocalizedString("sb_about_text", comment: ""), version, build) alert.alertStyle = .informational alert.addButton(withTitle: NSLocalizedString("sb_about_github", comment: "GitHub")) @@ -397,6 +430,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + func updateAppearances(to theme: String) { + let appearance: NSAppearance? + if theme == "dark" { + appearance = NSAppearance(named: .darkAqua) + } else if theme == "light" { + appearance = NSAppearance(named: .aqua) + } else { + appearance = nil + } + + NSApp.appearance = appearance + } } extension AppDelegate: NSWindowDelegate { @@ -443,4 +489,11 @@ struct MiniVolumeSlider: View { } .padding(.horizontal, 8) } -} \ No newline at end of file +} + +extension AppDelegate: NSMenuDelegate { + func menuDidClose(_ menu: NSMenu) { + status_item?.menu = nil + } +} + diff --git a/moonleaf/main/macpaperSaver.swift b/petalia/main/macpaperSaver.swift similarity index 97% rename from moonleaf/main/macpaperSaver.swift rename to petalia/main/macpaperSaver.swift index 52d357f..87298af 100644 --- a/moonleaf/main/macpaperSaver.swift +++ b/petalia/main/macpaperSaver.swift @@ -1,6 +1,6 @@ // // macpaperSaver.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // @@ -35,6 +35,7 @@ class macpaperSaverView: ScreenSaverView { private func loadWallpaperPath() { let home = FileManager.default.homeDirectoryForCurrentUser let dirs = [ + home.appendingPathComponent("Library/Application Support/petalia"), home.appendingPathComponent("Library/Application Support/moonleaf"), home.appendingPathComponent("Library/Application Support/macpaper"), ] diff --git a/moonleaf/main/macpaperService.swift b/petalia/main/macpaperService.swift similarity index 82% rename from moonleaf/main/macpaperService.swift rename to petalia/main/macpaperService.swift index 208a708..55ef928 100644 --- a/moonleaf/main/macpaperService.swift +++ b/petalia/main/macpaperService.swift @@ -1,6 +1,6 @@ // // macpaperService.swift -// moonleaf +// petalia // // Copyright ยฉ 2026 naomisphere. All rights reserved. // @@ -9,10 +9,22 @@ import Foundation import Combine import AppKit +@_silgen_name("system") +@discardableResult +private func c_system(_ command: UnsafePointer?) -> Int32 + +private func escapeShellArg(_ arg: String) -> String { + return "'" + arg.replacingOccurrences(of: "'", with: "'\\''") + "'" +} + class macpaperService: NSObject, ObservableObject { @Published var wallpapers: [endup_wp] = [] @Published var isLoading = false - @Published var current_wp: String? + @Published var current_wp: String? { + didSet { + UserDefaults.standard.set(current_wp, forKey: "petalia_current_wp") + } + } @Published var volume: Double = 0.5 @Published var wp_is_agent: Bool = false @Published var ap_is_enabled: Bool = false @@ -20,8 +32,13 @@ class macpaperService: NSObject, ObservableObject { @Published var showImages: Bool = true @Published var selected_wp: endup_wp? = nil @Published var favorites: Set = [] + @Published var showFavoritesOnly = false @Published var localSort: LocalSortMode = .date @Published var shuffleEnabled: Bool = false + @Published var previewWallpaper: endup_wp? = nil + @Published var showPreview: Bool = false + @Published var isSelectionMode: Bool = false + @Published var selectedWallpapers: Set = [] @Published var shuffleInterval: ShuffleInterval = .oneHour @Published var importMethod: ImportMethod = .link @Published var currentPath: URL? @@ -41,11 +58,11 @@ class macpaperService: NSObject, ObservableObject { private let wp_storage_dir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".local/share/paper/wallpaper") private let settings_file = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/settings.json") + .appendingPathComponent(".config/petalia/settings.json") private let export_folder_file = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/export_folder") + .appendingPathComponent(".config/petalia/export_folder") private let screen_wallpapers_file = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/screen_wallpapers.json") + .appendingPathComponent(".config/petalia/screen_wallpapers.json") private var shuffleTimer: DispatchSourceTimer? enum LocalSortMode: String, CaseIterable { @@ -92,12 +109,32 @@ class macpaperService: NSObject, ObservableObject { case link = "link" } + private func migrateUserDefaultsIfNeeded() { + let keysToMigrate = [ + "moonleaf_favorites": "petalia_favorites", + "moonleaf_localSort": "petalia_localSort", + "moonleaf_shuffleEnabled": "petalia_shuffleEnabled", + "moonleaf_shuffleInterval": "petalia_shuffleInterval", + "moonleaf_current_wp": "petalia_current_wp" + ] + + let defaults = UserDefaults.standard + for (oldKey, newKey) in keysToMigrate { + if defaults.object(forKey: newKey) == nil { + if let oldVal = defaults.object(forKey: oldKey) { + defaults.set(oldVal, forKey: newKey) + } + } + } + } + override init() { let app_path = Bundle.main.bundlePath - wrapped_obj = "\(app_path)/Contents/MacOS/moonleaf-bin" + wrapped_obj = "\(app_path)/Contents/MacOS/petalia-bin" wallpaper_cli = "\(app_path)/Contents/Resources/bin/wallpaper" glasswp_path = "\(app_path)/Contents/Resources/bin/glasswp" super.init() + migrateUserDefaultsIfNeeded() syncConfigs() loadSettings() loadVolume() @@ -118,10 +155,8 @@ class macpaperService: NSObject, ObservableObject { guard FileManager.default.fileExists(atPath: glasswp_path) else { return } - let task = Process() - task.launchPath = glasswp_path - task.arguments = ["--daemon"] - task.launch() + let command = "\(escapeShellArg(glasswp_path)) --daemon &" + c_system(command) } func select_wp(_ wallpaper: endup_wp?) { @@ -142,7 +177,7 @@ class macpaperService: NSObject, ObservableObject { } private func loadFavorites() { - if let data = UserDefaults.standard.data(forKey: "moonleaf_favorites"), + if let data = UserDefaults.standard.data(forKey: "petalia_favorites"), let paths = try? JSONDecoder().decode([String].self, from: data) { favorites = Set(paths) } @@ -150,13 +185,13 @@ class macpaperService: NSObject, ObservableObject { private func saveFavorites() { if let data = try? JSONEncoder().encode(Array(favorites)) { - UserDefaults.standard.set(data, forKey: "moonleaf_favorites") + UserDefaults.standard.set(data, forKey: "petalia_favorites") } } func setLocalSort(_ mode: LocalSortMode) { localSort = mode - UserDefaults.standard.set(mode.rawValue, forKey: "moonleaf_localSort") + UserDefaults.standard.set(mode.rawValue, forKey: "petalia_localSort") applyLocalSort() } @@ -173,7 +208,7 @@ class macpaperService: NSObject, ObservableObject { func setShuffleEnabled(_ enabled: Bool) { shuffleEnabled = enabled - UserDefaults.standard.set(enabled, forKey: "moonleaf_shuffleEnabled") + UserDefaults.standard.set(enabled, forKey: "petalia_shuffleEnabled") if enabled { startShuffleTimer() } else { @@ -184,7 +219,7 @@ class macpaperService: NSObject, ObservableObject { func setShuffleInterval(_ interval: ShuffleInterval) { shuffleInterval = interval - UserDefaults.standard.set(interval.rawValue, forKey: "moonleaf_shuffleInterval") + UserDefaults.standard.set(interval.rawValue, forKey: "petalia_shuffleInterval") if shuffleEnabled { startShuffleTimer() } @@ -214,7 +249,7 @@ class macpaperService: NSObject, ObservableObject { saveSettings() DistributedNotificationCenter.default().postNotificationName( - Notification.Name("com.naomisphere.moonleaf.autoPauseChanged"), + Notification.Name("com.naomisphere.petalia.autoPauseChanged"), object: nil, userInfo: ["ap_is_enabled": enabled], deliverImmediately: true @@ -240,22 +275,26 @@ class macpaperService: NSObject, ObservableObject { loadFavorites() - if let sortRaw = UserDefaults.standard.string(forKey: "moonleaf_localSort"), + if let sortRaw = UserDefaults.standard.string(forKey: "petalia_localSort"), let sort = LocalSortMode(rawValue: sortRaw) { localSort = sort } - shuffleEnabled = UserDefaults.standard.bool(forKey: "moonleaf_shuffleEnabled") + shuffleEnabled = UserDefaults.standard.bool(forKey: "petalia_shuffleEnabled") - if let intervalRaw = UserDefaults.standard.string(forKey: "moonleaf_shuffleInterval"), + if let intervalRaw = UserDefaults.standard.string(forKey: "petalia_shuffleInterval"), let interval = ShuffleInterval(rawValue: intervalRaw) { shuffleInterval = interval } + + if let savedWp = UserDefaults.standard.string(forKey: "petalia_current_wp") { + current_wp = savedWp + } } private func loadVolume() { let volFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/volume") + .appendingPathComponent(".config/petalia/volume") if let data = try? Data(contentsOf: volFile), let str = String(data: data, encoding: .utf8), let intVal = Int(str.trimmingCharacters(in: .whitespacesAndNewlines)) { @@ -285,7 +324,7 @@ class macpaperService: NSObject, ObservableObject { DistributedNotificationCenter.default().postNotificationName( - Notification.Name("com.naomisphere.moonleaf.volumeChanged"), + Notification.Name("com.naomisphere.petalia.volumeChanged"), object: nil, userInfo: ["volume": volumeFloat], deliverImmediately: true @@ -294,7 +333,7 @@ class macpaperService: NSObject, ObservableObject { let vol_in_percentage = Int(new_vol * 100) let volFile = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/moonleaf/volume") + .appendingPathComponent(".config/petalia/volume") try? "\(vol_in_percentage)".write(to: volFile, atomically: true, encoding: .utf8) } @@ -390,11 +429,13 @@ class macpaperService: NSObject, ObservableObject { guard isMoving || isStill else { return } - DispatchQueue.main.async { self.selected_wp = wallpaper } + DispatchQueue.main.async { + self.selected_wp = nil + self.current_wp = wallpaper.path + } copyWallpaperForScreensaver(wallpaper) - if isStill { let cliPath = Bundle.main.bundlePath + "/Contents/Resources/bin/wallpaper" if FileManager.default.fileExists(atPath: cliPath) { @@ -402,17 +443,14 @@ class macpaperService: NSObject, ObservableObject { guard let self = self else { return } self.postOverlayNotification(path: wallpaper.path) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.current_wp = wallpaper.path + DispatchQueue.main.async { self.screenWallpapers.removeAll() self.saveScreenWallpapers() } } } else { - self.postOverlayNotification(path: wallpaper.path) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.current_wp = wallpaper.path + DispatchQueue.main.async { self.screenWallpapers.removeAll() self.saveScreenWallpapers() } @@ -420,12 +458,9 @@ class macpaperService: NSObject, ObservableObject { return } - - self.postOverlayNotification(path: wallpaper.path) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - self.current_wp = wallpaper.path + DispatchQueue.main.async { self.screenWallpapers.removeAll() self.saveScreenWallpapers() } @@ -433,7 +468,7 @@ class macpaperService: NSObject, ObservableObject { private func postOverlayNotification(path: String) { DistributedNotificationCenter.default().postNotificationName( - Notification.Name("com.naomisphere.moonleaf.changeWallpaper"), + Notification.Name("com.naomisphere.petalia.changeWallpaper"), object: nil, userInfo: ["filePath": path], deliverImmediately: true @@ -441,15 +476,8 @@ class macpaperService: NSObject, ObservableObject { } private func checkIfGlasswpIsRunning() -> Bool { - let task = Process() - task.launchPath = "/usr/bin/pgrep" - task.arguments = ["-f", "glasswp"] - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - task.launch() - task.waitUntilExit() - return task.terminationStatus == 0 + let status = c_system("pgrep -f glasswp > /dev/null 2>&1") + return (status >> 8) == 0 } func set_wp_on_screen(_ wallpaper: endup_wp, screenIndex: Int) { @@ -466,7 +494,7 @@ class macpaperService: NSObject, ObservableObject { DistributedNotificationCenter.default().postNotificationName( - Notification.Name("com.naomisphere.moonleaf.changeWallpaper"), + Notification.Name("com.naomisphere.petalia.changeWallpaper"), object: nil, userInfo: ["filePath": wallpaper.path, "screenIndex": screenIndex], deliverImmediately: true @@ -513,11 +541,7 @@ class macpaperService: NSObject, ObservableObject { current_wp = nil wp_is_agent = false - let killTask = Process() - killTask.launchPath = "/usr/bin/pkill" - killTask.arguments = ["-9", "-f", "glasswp"] - try? killTask.run() - killTask.waitUntilExit() + c_system("pkill -9 -f glasswp > /dev/null 2>&1") _exec([wrapped_obj, "--unset"]) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -541,11 +565,7 @@ class macpaperService: NSObject, ObservableObject { } else { let home = FileManager.default.homeDirectoryForCurrentUser let launchAgent = home.appendingPathComponent("Library/LaunchAgents/com.naomisphere.macpaper.wallpaper.plist") - let unload = Process() - unload.launchPath = "/bin/launchctl" - unload.arguments = ["unload", launchAgent.path] - try? unload.run() - unload.waitUntilExit() + c_system("launchctl unload \(escapeShellArg(launchAgent.path)) > /dev/null 2>&1") try? FileManager.default.removeItem(at: launchAgent) DispatchQueue.main.async { self.wp_is_agent = false } } @@ -567,15 +587,9 @@ class macpaperService: NSObject, ObservableObject { private func _exec(_ arguments: [String], completion: @escaping (Bool) -> Void) { DispatchQueue.global(qos: .background).async { - let task = Process() - task.launchPath = arguments[0] - task.arguments = Array(arguments.dropFirst()) - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - task.launch() - task.waitUntilExit() - DispatchQueue.main.async { completion(task.terminationStatus == 0) } + let command = arguments.map { escapeShellArg($0) }.joined(separator: " ") + let status = c_system(command) + DispatchQueue.main.async { completion((status >> 8) == 0) } } } @@ -586,15 +600,10 @@ class macpaperService: NSObject, ObservableObject { return } DispatchQueue.global(qos: .background).async { - let task = Process() - task.launchPath = cliPath - task.arguments = arguments - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - task.launch() - task.waitUntilExit() - DispatchQueue.main.async { completion(task.terminationStatus == 0) } + let cmdArgs = [cliPath] + arguments + let command = cmdArgs.map { escapeShellArg($0) }.joined(separator: " ") + let status = c_system(command) + DispatchQueue.main.async { completion((status >> 8) == 0) } } } @@ -665,19 +674,28 @@ class macpaperService: NSObject, ObservableObject { private func syncConfigs() { let home = FileManager.default.homeDirectoryForCurrentUser let legacyDir = home.appendingPathComponent(".local/share/macpaper") - let newDir = home.appendingPathComponent(".config/moonleaf") + let moonleafDir = home.appendingPathComponent(".config/moonleaf") + let petaliaDir = home.appendingPathComponent(".config/petalia") - guard FileManager.default.fileExists(atPath: legacyDir.path) else { return } - try? FileManager.default.createDirectory(at: newDir, withIntermediateDirectories: true) - - if let files = try? FileManager.default.contentsOfDirectory(at: legacyDir, includingPropertiesForKeys: nil) { - for file in files { - let dest = newDir.appendingPathComponent(file.lastPathComponent) - if !FileManager.default.fileExists(atPath: dest.path) { - try? FileManager.default.createSymbolicLink(at: dest, withDestinationURL: file) + let fm = FileManager.default + + if !fm.fileExists(atPath: petaliaDir.path) { + if fm.fileExists(atPath: moonleafDir.path) { + try? fm.copyItem(at: moonleafDir, to: petaliaDir) + } else if fm.fileExists(atPath: legacyDir.path) { + try? fm.createDirectory(at: petaliaDir, withIntermediateDirectories: true) + if let files = try? fm.contentsOfDirectory(at: legacyDir, includingPropertiesForKeys: nil) { + for file in files { + let dest = petaliaDir.appendingPathComponent(file.lastPathComponent) + if !fm.fileExists(atPath: dest.path) { + try? fm.createSymbolicLink(at: dest, withDestinationURL: file) + } + } } } } + + try? fm.createDirectory(at: petaliaDir, withIntermediateDirectories: true) } @@ -716,7 +734,7 @@ class macpaperService: NSObject, ObservableObject { for (idx, path) in loaded { guard FileManager.default.fileExists(atPath: path) else { continue } DistributedNotificationCenter.default().postNotificationName( - Notification.Name("com.naomisphere.moonleaf.changeWallpaper"), + Notification.Name("com.naomisphere.petalia.changeWallpaper"), object: nil, userInfo: ["filePath": path, "screenIndex": idx], deliverImmediately: true diff --git a/moonleaf/obj/moonleaf.c b/petalia/obj/petalia.c similarity index 80% rename from moonleaf/obj/moonleaf.c rename to petalia/obj/petalia.c index e884ae6..554b08a 100644 --- a/moonleaf/obj/moonleaf.c +++ b/petalia/obj/petalia.c @@ -10,12 +10,12 @@ #include #include -#define PREFIX "/Applications/macpaper.app/Contents/MacOS" +#define PREFIX "/Applications/petalia.app/Contents/MacOS" #define BUFFER_SIZE BUFSIZ #define ERR_PREFIX "error:" void p_usage(const char *cl) { - fprintf(stderr, "macpaper - The macOS Wallpaper Manager\n\ + fprintf(stderr, "petalia - The macOS Wallpaper Manager\n\ Usage: %s [ OPTION ] [ FILE ] ...\n\n\ Options:\n\ --set [ FILE ] set FILE (.mov, .mp4, .gif) as wallpaper\n\ @@ -36,14 +36,14 @@ int n_la(const char *wp_img) { char launch_agent[BUFFER_SIZE]; snprintf(launch_agent, sizeof(launch_agent), - "%s/Library/LaunchAgents/com.naomisphere.macpaper.wallpaper.plist", + "%s/Library/LaunchAgents/com.naomisphere.petalia.wallpaper.plist", home); FILE *agent_file = fopen(launch_agent, "w"); if (!agent_file) { perror("Failed to create LaunchAgent plist"); system("echo 'APP: failed to create LaunchAgent plist (wallpaper will not " - "persist)' >> /tmp/macpaper.log"); + "persist)' >> /tmp/petalia.log"); return 1; } @@ -52,10 +52,10 @@ int n_la(const char *wp_img) { if (_NSGetExecutablePath(gwp_bin, &size) == 0) { char *dir = dirname(gwp_bin); snprintf(gwp_bin, sizeof(gwp_bin), - "%s/macpaper Wallpaper Service (glasswp)", dir); + "%s/petalia Wallpaper Service (glasswp)", dir); } else { snprintf(gwp_bin, sizeof(gwp_bin), - "/Applications/macpaper.app/Contents/MacOS/macpaper Wallpaper " + "/Applications/petalia.app/Contents/MacOS/petalia Wallpaper " "Service (glasswp)"); } @@ -66,7 +66,7 @@ int n_la(const char *wp_img) { "\n" "\n" " Label\n" - " com.naomisphere.macpaper.wallpaper\n" + " com.naomisphere.petalia.wallpaper\n" " ProgramArguments\n" " \n" " %s\n" @@ -77,9 +77,9 @@ int n_la(const char *wp_img) { " KeepAlive\n" " \n" " StandardOutPath\n" - " /tmp/macpaper.log\n" + " /tmp/petalia.log\n" " StandardErrorPath\n" - " /tmp/macpaper.log\n" + " /tmp/petalia.log\n" "\n" "", gwp_bin, wp_img); @@ -92,8 +92,8 @@ int n_la(const char *wp_img) { printf("\nwallpaper will now start automatically on login.\n"); printf("to disable this, run:\n"); - printf("launchctl disable gui/$UID/com.naomisphere.macpaper.wallpaper\n"); - printf("(to undo, enable again or re-run macpaper)\n"); + printf("launchctl disable gui/$UID/com.naomisphere.petalia.wallpaper\n"); + printf("(to undo, enable again or re-run petalia)\n"); */ return 0; } @@ -102,7 +102,7 @@ int upa_off(const char *type) { char *home = getenv("HOME"); char launch_agent[BUFFER_SIZE]; snprintf(launch_agent, sizeof(launch_agent), - "%s/Library/LaunchAgents/com.naomisphere.macpaper.%s.plist", home, + "%s/Library/LaunchAgents/com.naomisphere.petalia.%s.plist", home, type); char lctl_unload[BUFFER_SIZE * 2]; @@ -122,7 +122,7 @@ int set_wp(const char *wp_img) { char _wp[BUFFER_SIZE]; - snprintf(_wp, sizeof(_wp), "%s/.local/share/macpaper/current_wallpaper", + snprintf(_wp, sizeof(_wp), "%s/.local/share/petalia/current_wallpaper", home); FILE *wp_file = fopen(_wp, "w"); if (wp_file) { @@ -131,11 +131,11 @@ int set_wp(const char *wp_img) { } upa_off("wallpaper"); - system("pkill -9 -f 'macpaper Wallpaper Service (glasswp)' 'glasswp' " + system("pkill -9 -f 'petalia Wallpaper Service (glasswp)' 'glasswp' " "'glasswp*' 2>/dev/null || true"); char user_wp[BUFFER_SIZE]; - snprintf(user_wp, sizeof(user_wp), "%s/macpaper Wallpaper Service (glasswp)", + snprintf(user_wp, sizeof(user_wp), "%s/petalia Wallpaper Service (glasswp)", PREFIX); char cmd[BUFFER_SIZE]; @@ -144,17 +144,17 @@ int set_wp(const char *wp_img) { - system("pkill -9 -f 'macpaper.*Wallpaper.*Service' 2>/dev/null || true"); + system("pkill -9 -f 'petalia.*Wallpaper.*Service' 2>/dev/null || true"); system("pkill -9 -f 'glasswp' 2>/dev/null || true"); uint32_t size = sizeof(glasswp); if (_NSGetExecutablePath(glasswp, &size) == 0) { char *dir = dirname(glasswp); snprintf(glasswp, sizeof(glasswp), - "%s/macpaper Wallpaper Service (glasswp)", dir); + "%s/petalia Wallpaper Service (glasswp)", dir); } else { snprintf(glasswp, sizeof(glasswp), - "%s/macpaper Wallpaper Service (glasswp)", PREFIX); + "%s/petalia Wallpaper Service (glasswp)", PREFIX); } pid_t pid = fork(); @@ -166,7 +166,7 @@ int set_wp(const char *wp_img) { char _wp[BUFFER_SIZE]; printf("wallpaper service started with PID %d\n", pid); printf("use --persist to create persistence launchagent\n"); - snprintf(_wp, sizeof(_wp), "%s/.local/share/macpaper/current_wallpaper", + snprintf(_wp, sizeof(_wp), "%s/.local/share/petalia/current_wallpaper", home); FILE *file = fopen(_wp, "w"); @@ -197,11 +197,11 @@ int set_volume(const char *volume_str) { } char volume_file[BUFFER_SIZE]; - snprintf(volume_file, sizeof(volume_file), "%s/.local/share/macpaper/volume", + snprintf(volume_file, sizeof(volume_file), "%s/.local/share/petalia/volume", home); char dir_cmd[BUFFER_SIZE]; - snprintf(dir_cmd, sizeof(dir_cmd), "mkdir -p %s/.local/share/macpaper", home); + snprintf(dir_cmd, sizeof(dir_cmd), "mkdir -p %s/.local/share/petalia", home); system(dir_cmd); FILE *vol_file = fopen(volume_file, "w"); @@ -223,14 +223,14 @@ int unset_wp() { char _wp[BUFFER_SIZE]; char cmd[BUFFER_SIZE]; - system("pkill -f 'macpaper Wallpaper Service' 2>/dev/null || true"); - snprintf(user_wp, sizeof(user_wp), "%s/macpaper Wallpaper Service (glasswp)", + system("pkill -f 'petalia Wallpaper Service' 2>/dev/null || true"); + snprintf(user_wp, sizeof(user_wp), "%s/petalia Wallpaper Service (glasswp)", PREFIX); snprintf(cmd, sizeof(cmd), "pkill -f '%s' 2>/dev/null || true", user_wp); system(cmd); upa_off("wallpaper"); - snprintf(_wp, sizeof(_wp), "%s/.local/share/macpaper/current_wallpaper", + snprintf(_wp, sizeof(_wp), "%s/.local/share/petalia/current_wallpaper", home); remove(_wp); @@ -242,7 +242,7 @@ int disable_persistence_only() { char *home = getenv("HOME"); char launch_agent[BUFFER_SIZE]; snprintf(launch_agent, sizeof(launch_agent), - "%s/Library/LaunchAgents/com.naomisphere.macpaper.wallpaper.plist", + "%s/Library/LaunchAgents/com.naomisphere.petalia.wallpaper.plist", home); char lctl_unload[BUFFER_SIZE * 2]; @@ -285,7 +285,7 @@ int main(int argc, char *argv[]) { char *home = getenv("HOME"); char current_wp[BUFFER_SIZE]; char _wp[BUFFER_SIZE]; - snprintf(_wp, sizeof(_wp), "%s/.local/share/macpaper/current_wallpaper", + snprintf(_wp, sizeof(_wp), "%s/.local/share/petalia/current_wallpaper", home); FILE *wp_file = fopen(_wp, "r"); if (wp_file && fgets(current_wp, sizeof(current_wp), wp_file)) { diff --git a/moonleaf/resources/Comfortaa-Bold.ttf b/petalia/resources/Comfortaa-Bold.ttf similarity index 100% rename from moonleaf/resources/Comfortaa-Bold.ttf rename to petalia/resources/Comfortaa-Bold.ttf diff --git a/moonleaf/resources/Comfortaa-Light.ttf b/petalia/resources/Comfortaa-Light.ttf similarity index 100% rename from moonleaf/resources/Comfortaa-Light.ttf rename to petalia/resources/Comfortaa-Light.ttf diff --git a/moonleaf/resources/Comfortaa-Regular.ttf b/petalia/resources/Comfortaa-Regular.ttf similarity index 100% rename from moonleaf/resources/Comfortaa-Regular.ttf rename to petalia/resources/Comfortaa-Regular.ttf diff --git a/moonleaf/resources/bin/LICENSES.txt b/petalia/resources/bin/LICENSES.txt similarity index 100% rename from moonleaf/resources/bin/LICENSES.txt rename to petalia/resources/bin/LICENSES.txt diff --git a/petalia/resources/bin/wallpaper b/petalia/resources/bin/wallpaper new file mode 100755 index 0000000..1d466f1 Binary files /dev/null and b/petalia/resources/bin/wallpaper differ diff --git a/moonleaf/resources/bin/wallpaper.gz b/petalia/resources/bin/wallpaper.gz similarity index 100% rename from moonleaf/resources/bin/wallpaper.gz rename to petalia/resources/bin/wallpaper.gz diff --git a/moonleaf/updater.sh b/petalia/updater.sh similarity index 100% rename from moonleaf/updater.sh rename to petalia/updater.sh diff --git a/petalia/utils/QuickPreviewOverlay.swift b/petalia/utils/QuickPreviewOverlay.swift new file mode 100644 index 0000000..1dcecc3 --- /dev/null +++ b/petalia/utils/QuickPreviewOverlay.swift @@ -0,0 +1,936 @@ +// +// QuickPreviewOverlay.swift +// moonleaf +// +// Copyright ยฉ 2026 naomisphere. All rights reserved. +// + +import SwiftUI +import AVKit + +struct QuickPreviewOverlay: View { + let wallpaper: endup_wp + let onClose: () -> Void + + enum ContentModeOption: String, CaseIterable, Identifiable { + case fit = "Fit" + case fill = "Fill" + case stretch = "Stretch" + + var id: String { self.rawValue } + } + + @State private var scalingOption: ContentModeOption = .fit + @State private var isHovered = false + @State private var currentTime = Date() + @State private var isVisible = false + @State private var player: AVPlayer? + @State private var isVideoPlaying = true + @State private var loadedImage: NSImage? + @State private var isImageLoading = false + @State private var imageLoadFailed = false + @Namespace private var tabAnimation + @State private var isDownloading = false + @State private var downloadProgress: Double = 0 + @State private var downloadTask: URLSessionDownloadTask? + @State private var progressObservation: NSKeyValueObservation? + @EnvironmentObject private var service: macpaperService + + @AppStorage("clockFontSize") private var clockFontSize: Double = 120.0 + @AppStorage("clockFontDesign") private var clockFontDesign: String = "rounded" + @AppStorage("clockFontWeight") private var clockFontWeight: String = "semibold" + @AppStorage("clockColorType") private var clockColorType: String = "white" + @AppStorage("clockCustomColorHex") private var clockCustomColorHex: String = "#FFFFFF" + + @State private var showEditPanel = false + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + private var fileExtension: String { + if wallpaper.path.hasPrefix("/") { + return (wallpaper.path as NSString).pathExtension.lowercased() + } else if let url = URL(string: wallpaper.path) { + return url.pathExtension.lowercased() + } + return (wallpaper.path as NSString).pathExtension.lowercased() + } + + var body: some View { ZStack { + // Background + Color.black + + // Media Content (Full View) + GeometryReader { geo in + Group { + let ext = fileExtension + + if ["mp4", "mov"].contains(ext) { + if let player = player { + CustomVideoPlayer(player: player, scalingOption: scalingOption) + .onAppear { + if isVideoPlaying { + player.play() + } + } + .onDisappear { + player.pause() + } + .onTapGesture { + if isVideoPlaying { + player.pause() + } else { + player.play() + } + isVideoPlaying.toggle() + } + .overlay( + Group { + if !isVideoPlaying { + Image(systemName: "play.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.white.opacity(0.6)) + .shadow(color: .black.opacity(0.5), radius: 10) + } + } + ) + } else { + loadingPlaceholder + } + } else if ext == "gif" { + if isImageLoading { + loadingPlaceholder + } else if imageLoadFailed { + errorPlaceholder + } else if let image = loadedImage { + GIFAnimatingView(image: image, scalingOption: scalingOption) + } else { + loadingPlaceholder + } + } else if ["jpg", "jpeg", "png"].contains(ext) { + if isImageLoading { + loadingPlaceholder + } else if imageLoadFailed { + errorPlaceholder + } else if let image = loadedImage { + let customAspectRatio = (scalingOption == .stretch) ? (geo.size.height > 0 ? geo.size.width / geo.size.height : 1) : nil + let contentMode: ContentMode = (scalingOption == .fill) ? .fill : .fit + + Image(nsImage: image) + .resizable() + .aspectRatio(customAspectRatio, contentMode: contentMode) + .clipped() + } else { + loadingPlaceholder + } + } else { + Color.gray.opacity(0.3) + .overlay { + Image(systemName: "photo") + .font(.largeTitle) + .foregroundColor(.white.opacity(0.5)) + } + } + } + .frame(width: geo.size.width, height: geo.size.height) + } + + // Time Display Overlay + VStack(spacing: 0) { + HStack { + Spacer() + VStack(spacing: 0) { + Text(formatDate(currentTime)) + .font(.system(size: 24, weight: .medium, design: .rounded)) + .foregroundStyle(getClockColor().opacity(0.9)) + .padding(.bottom, -3) + .shadow(color: .black.opacity(0.3), radius: 4, x: 0, y: 2) + + Text(formatTime(currentTime)) + .font(getClockFont(size: CGFloat(clockFontSize))) + .foregroundStyle(getClockColor()) + .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) + } + .padding(.top, 80) + Spacer() + } + Spacer() + } + + // Top Left Close Button & Top Right Edit Button + VStack { + HStack { + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background( + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.white.opacity(0.2), lineWidth: 0.5) + } + ) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) + } + .buttonStyle(.plain) + .help("Close Preview") + .padding(.leading, 24) + .padding(.top, 24) + + Spacer() + + Button(action: { + showEditPanel.toggle() + }) { + Image(systemName: "pencil") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 36, height: 36) + .background( + Circle() + .fill(.ultraThinMaterial) + .overlay { + Circle() + .stroke(.white.opacity(0.2), lineWidth: 0.5) + } + ) + .shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2) + } + .buttonStyle(.plain) + .help("Customize Clock") + .padding(.trailing, 24) + .padding(.top, 24) + .popover(isPresented: $showEditPanel, arrowEdge: .bottom) { + editPanelContent + } + } + Spacer() + } + + + + // Bottom Info & Controls Bar + VStack { + Spacer() + + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 2) { + Text(wallpaper.name) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(.white.opacity(0.9)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150, alignment: .leading) + + let ext = fileExtension + let fileType = ["mp4", "mov"].contains(ext) ? "Video" : + ext == "gif" ? "GIF" : + ["jpg", "jpeg", "png"].contains(ext) ? "Image" : "File" + let sizeString = wallpaper.fileSize > 0 ? " โ€ข \(ByteCountFormatter.string(fromByteCount: wallpaper.fileSize, countStyle: .file))" : " โ€ข Online" + Text("\(fileType)\(sizeString)") + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundColor(.white.opacity(0.5)) + } + + Divider().frame(height: 24).background(Color.white.opacity(0.2)) + + // Aspect ratio / scaling selector + HStack(spacing: 2) { + ForEach(ContentModeOption.allCases) { option in + ScalingTabButton( + title: option.rawValue, + isSelected: scalingOption == option, + namespace: tabAnimation, + action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + scalingOption = option + } + } + ) + } + } + .padding(2) + .background { + Capsule() + .fill(.black.opacity(0.2)) + .overlay { + Capsule() + .stroke(.white.opacity(0.1), lineWidth: 0.5) + } + } + + if ["mp4", "mov"].contains(fileExtension) { + Divider().frame(height: 24).background(Color.white.opacity(0.2)) + + Button(action: { + if isVideoPlaying { + player?.pause() + } else { + player?.play() + } + isVideoPlaying.toggle() + }) { + Image(systemName: isVideoPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.5), radius: 5) + } + .buttonStyle(.plain) + } + + if !wallpaper.path.hasPrefix("http://") && !wallpaper.path.hasPrefix("https://") { + Divider().frame(height: 24).background(Color.white.opacity(0.2)) + + Button(action: { + service.set_wp(wallpaper) + }) { + HStack(spacing: 6) { + Image(systemName: "wand.and.stars") + .font(.system(size: 11, weight: .semibold)) + Text(NSLocalizedString("set_wallpaper", comment: "set wallpaper")) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } + } + .buttonStyle(MaterialButtonStyle()) + } else { + Divider().frame(height: 24).background(Color.white.opacity(0.2)) + + if isDownloading { + HStack(spacing: 8) { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 2) + Circle() + .trim(from: 0, to: max(0.05, downloadProgress)) + .stroke(Color.white, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 14, height: 14) + + Text("\(Int(downloadProgress * 100))%") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } else { + Button(action: downloadWallpaper) { + HStack(spacing: 6) { + Image(systemName: "arrow.down") + .font(.system(size: 11, weight: .semibold)) + Text("Download") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } + } + .buttonStyle(MaterialButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background { + Capsule() + .fill(.ultraThinMaterial) + .overlay { + Capsule() + .stroke(.white.opacity(0.15), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) + } + .padding(.bottom, 32) + } + .opacity(isVisible ? 1 : 0) + .animation(.easeOut(duration: 0.3).delay(0.2), value: isVisible) + } + .ignoresSafeArea() + .onAppear { + let ext = fileExtension + if ["mp4", "mov"].contains(ext) { + let url: URL + if wallpaper.path.hasPrefix("http://") || wallpaper.path.hasPrefix("https://") { + url = URL(string: wallpaper.path)! + } else { + url = URL(fileURLWithPath: wallpaper.path) + } + player = AVPlayer(url: url) + player?.volume = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + player?.play() + } + } else if ["jpg", "jpeg", "png", "gif"].contains(ext) { + isImageLoading = true + imageLoadFailed = false + let path = wallpaper.path + DispatchQueue.global(qos: .userInitiated).async { + let image: NSImage? + if path.hasPrefix("http://") || path.hasPrefix("https://") { + if let url = URL(string: path), let data = try? Data(contentsOf: url) { + image = NSImage(data: data) + } else { + image = nil + } + } else { + image = NSImage(contentsOfFile: path) + } + DispatchQueue.main.async { + if let image = image { + self.loadedImage = image + } else { + self.imageLoadFailed = true + } + self.isImageLoading = false + } + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isVisible = true + } + } + .onDisappear { + player?.pause() + player = nil + downloadTask?.cancel() + progressObservation?.invalidate() + } + .onReceive(timer) { _ in + currentTime = Date() + } + } + + private func formatTime(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "h:mm" + return formatter.string(from: date) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d" + return formatter.string(from: date) + } + + private func getClockFont(size: CGFloat) -> Font { + let weight: Font.Weight + switch clockFontWeight { + case "light": weight = .light + case "regular": weight = .regular + case "medium": weight = .medium + case "bold": weight = .bold + case "heavy": weight = .heavy + default: weight = .semibold + } + + let design: Font.Design + switch clockFontDesign { + case "monospaced": design = .monospaced + case "serif": design = .serif + case "default": design = .default + default: design = .rounded + } + + return .system(size: size, weight: weight, design: design) + } + + private func getClockColor() -> AnyShapeStyle { + switch clockColorType { + case "white": + return AnyShapeStyle(Color.white.opacity(0.95)) + case "gradient1": + return AnyShapeStyle( + LinearGradient( + colors: [Color(red: 1.0, green: 0.85, blue: 0.3), Color(red: 1.0, green: 0.4, blue: 0.4)], + startPoint: .top, + endPoint: .bottom + ) + ) + case "blue": + return AnyShapeStyle(Color(red: 0.35, green: 0.78, blue: 0.98)) + case "lavender": + return AnyShapeStyle(Color(red: 0.68, green: 0.68, blue: 0.98)) + case "pink": + return AnyShapeStyle(Color(red: 0.98, green: 0.68, blue: 0.88)) + case "gradient2": + return AnyShapeStyle( + LinearGradient( + colors: [Color(red: 0.3, green: 0.9, blue: 0.7), Color(red: 0.2, green: 0.5, blue: 1.0)], + startPoint: .top, + endPoint: .bottom + ) + ) + case "custom": + return AnyShapeStyle(Color(hex: clockCustomColorHex) ?? .white) + default: + return AnyShapeStyle(Color.white.opacity(0.95)) + } + } + + struct ColorPreset { + let type: String + let fill: AnyShapeStyle + } + + private var colorPresets: [ColorPreset] { + [ + ColorPreset(type: "white", fill: AnyShapeStyle(Color.white)), + ColorPreset(type: "gradient1", fill: AnyShapeStyle( + LinearGradient( + colors: [Color(red: 1.0, green: 0.85, blue: 0.3), Color(red: 1.0, green: 0.4, blue: 0.4)], + startPoint: .top, + endPoint: .bottom + ) + )), + ColorPreset(type: "blue", fill: AnyShapeStyle(Color(red: 0.35, green: 0.78, blue: 0.98))), + ColorPreset(type: "lavender", fill: AnyShapeStyle(Color(red: 0.68, green: 0.68, blue: 0.98))), + ColorPreset(type: "pink", fill: AnyShapeStyle(Color(red: 0.98, green: 0.68, blue: 0.88))), + ColorPreset(type: "gradient2", fill: AnyShapeStyle( + LinearGradient( + colors: [Color(red: 0.3, green: 0.9, blue: 0.7), Color(red: 0.2, green: 0.5, blue: 1.0)], + startPoint: .top, + endPoint: .bottom + ) + )) + ] + } + + private func getFontForPreview(design: String) -> Font { + let systemDesign: Font.Design + switch design { + case "monospaced": systemDesign = .monospaced + case "serif": systemDesign = .serif + case "default": systemDesign = .default + default: systemDesign = .rounded + } + return .system(size: 20, weight: .bold, design: systemDesign) + } + + private var customColorBinding: Binding { + Binding( + get: { Color(hex: clockCustomColorHex) ?? .white }, + set: { newColor in + if let nsColor = NSColor(newColor).usingColorSpace(.sRGB) { + let r = Int(nsColor.redComponent * 255) + let g = Int(nsColor.greenComponent * 255) + let b = Int(nsColor.blueComponent * 255) + clockCustomColorHex = String(format: "#%02X%02X%02X", r, g, b) + } + } + ) + } + + private var loadingPlaceholder: some View { + ZStack { + Color.black + VStack(spacing: 12) { + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + Text("Loading Preview...") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(.white.opacity(0.6)) + } + } + } + + private var errorPlaceholder: some View { + Color.gray.opacity(0.3) + .overlay { + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundColor(.white.opacity(0.5)) + } + } + + private var editPanelContent: some View { + VStack(spacing: 20) { + // Header + HStack { + Text("Font & Color") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + } + .padding(.horizontal, 16) + .padding(.top, 16) + + // Font Selection Row + HStack(spacing: 16) { + ForEach(["rounded", "monospaced", "serif", "default"], id: \.self) { design in + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + clockFontDesign = design + } + }) { + Text("12") + .font(getFontForPreview(design: design)) + .foregroundColor(.primary) + .frame(width: 48, height: 48) + .background { + RoundedRectangle(cornerRadius: 12) + .fill(clockFontDesign == design ? Color.accent.opacity(0.1) : Color.primary.opacity(0.05)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(clockFontDesign == design ? Color.accent : Color.clear, lineWidth: 2) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + + // Size Slider Row + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Size") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + Spacer() + Text("\(Int(clockFontSize))") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + } + + Slider(value: $clockFontSize, in: 48...240) + .tint(Color.accentColor) + } + .padding(.horizontal, 16) + + // Color Selection Row + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + .padding(.horizontal, 16) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + // Custom Color Picker Circle + ZStack { + AngularGradient( + colors: [.red, .yellow, .green, .blue, .purple, .red], + center: .center + ) + .clipShape(Circle()) + .frame(width: 32, height: 32) + + ColorPicker("", selection: customColorBinding) + .labelsHidden() + .scaleEffect(1.5) + .opacity(0.015) + .frame(width: 32, height: 32) + .clipped() + } + .frame(width: 32, height: 32) + .overlay { + if clockColorType == "custom" { + Circle() + .stroke(Color.accent, lineWidth: 2) + .frame(width: 38, height: 38) + } + } + .onTapGesture { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + clockColorType = "custom" + } + } + + // Presets + ForEach(colorPresets, id: \.type) { preset in + Button(action: { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + clockColorType = preset.type + } + }) { + Circle() + .fill(preset.fill) + .frame(width: 32, height: 32) + .overlay { + if clockColorType == preset.type { + Circle() + .stroke(Color.accent, lineWidth: 2) + .frame(width: 38, height: 38) + } + } + } + .buttonStyle(.plain) + } + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + } + + // Font Weight Selection Row + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Weight") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + Spacer() + Text(clockFontWeight.capitalized) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + } + .padding(.horizontal, 16) + + HStack(spacing: 12) { + Text("A") + .font(.system(size: 14, weight: .light, design: .rounded)) + .foregroundColor(.secondary) + + CustomWeightSlider(weight: $clockFontWeight) + + Text("A") + .font(.system(size: 14, weight: .heavy, design: .rounded)) + .foregroundColor(.primary) + } + .padding(.horizontal, 16) + } + .padding(.bottom, 20) + } + .frame(width: 290) + .padding(.vertical, 8) + } + + private func downloadWallpaper() { + guard let downloadURL = URL(string: wallpaper.path) else { return } + + let home = FileManager.default.homeDirectoryForCurrentUser + let wpStorageDir = home.appendingPathComponent(".local/share/paper/wallpaper") + + do { + try FileManager.default.createDirectory(at: wpStorageDir, withIntermediateDirectories: true) + + let fileName = "\(wallpaper.name)-\(downloadURL.lastPathComponent)" + let destinationURL = wpStorageDir.appendingPathComponent(fileName) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + return + } + + isDownloading = true + downloadProgress = 0 + + downloadTask = URLSession.shared.downloadTask(with: downloadURL) { tempURL, response, error in + DispatchQueue.main.async { + self.isDownloading = false + self.progressObservation?.invalidate() + self.progressObservation = nil + } + + if let _ = error { + return + } + + guard let tempURL = tempURL else { return } + + do { + if FileManager.default.fileExists(atPath: destinationURL.path) { + try? FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: tempURL, to: destinationURL) + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: NSNotification.Name("WallpaperDownloadCompleted"), + object: nil + ) + // Update preview wallpaper to the local path + let newWp = endup_wp( + id: wallpaper.id, + name: wallpaper.name, + path: destinationURL.path, + preview: wallpaper.preview, + createdDate: wallpaper.createdDate, + fileSize: wallpaper.fileSize + ) + withAnimation(.easeInOut(duration: 0.3)) { + service.previewWallpaper = newWp + } + } + } catch {} + } + + progressObservation = downloadTask?.progress.observe(\.fractionCompleted) { progress, _ in + DispatchQueue.main.async { + self.downloadProgress = progress.fractionCompleted + } + } + + downloadTask?.resume() + + } catch { + isDownloading = false + downloadProgress = 0 + } + } +} + +extension Color { + func toHex() -> String? { + let nsColor = NSColor(self) + guard let rgbColor = nsColor.usingColorSpace(.sRGB) else { return nil } + let r = Int(rgbColor.redComponent * 255) + let g = Int(rgbColor.greenComponent * 255) + let b = Int(rgbColor.blueComponent * 255) + return String(format: "#%02X%02X%02X", r, g, b) + } +} + +struct CustomVideoPlayer: NSViewRepresentable { + let player: AVPlayer + let scalingOption: QuickPreviewOverlay.ContentModeOption + + func makeNSView(context: Context) -> AVPlayerView { + let view = AVPlayerView() + view.player = player + view.controlsStyle = .none + return view + } + + func updateNSView(_ nsView: AVPlayerView, context: Context) { + nsView.player = player + switch scalingOption { + case .fit: + nsView.videoGravity = .resizeAspect + case .fill: + nsView.videoGravity = .resizeAspectFill + case .stretch: + nsView.videoGravity = .resize + } + } +} + +struct GIFAnimatingView: NSViewRepresentable { + let image: NSImage + let scalingOption: QuickPreviewOverlay.ContentModeOption + + func makeNSView(context: Context) -> NSImageView { + let imageView = NSImageView() + imageView.animates = true + return imageView + } + + func updateNSView(_ nsView: NSImageView, context: Context) { + nsView.image = image + + switch scalingOption { + case .fit: + nsView.imageScaling = .scaleProportionallyUpOrDown + nsView.wantsLayer = false + case .stretch: + nsView.imageScaling = .scaleAxesIndependently + nsView.wantsLayer = false + case .fill: + nsView.imageScaling = .scaleNone + nsView.wantsLayer = true + nsView.layer?.contentsGravity = .resizeAspectFill + } + } +} + +struct ScalingTabButton: View { + let title: String + let isSelected: Bool + let namespace: Namespace.ID + let action: () -> Void + + @State private var isHovered = false + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(isSelected ? .black : (isHovered ? .white : .white.opacity(0.6))) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + if isSelected { + Capsule() + .fill(Color.white) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + .matchedGeometryEffect(id: "activeScalingTab", in: namespace) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { isHovered = hovering } + } + } +} + +struct CustomWeightSlider: View { + @Binding var weight: String + let weights = ["light", "regular", "medium", "semibold", "bold", "heavy"] + + private var weightIndex: Double { + Double(weights.firstIndex(of: weight) ?? 3) + } + + var body: some View { + GeometryReader { geometry in + let width = geometry.size.width + let stepWidth = width / CGFloat(max(1, weights.count - 1)) + let currentX = CGFloat(weightIndex) * stepWidth + + ZStack(alignment: .leading) { + // Background Track (Thin to Thick) + TrackShape() + .fill(Color.primary.opacity(0.1)) + + // Active Track + TrackShape() + .fill(Color.accentColor) + .mask( + HStack(spacing: 0) { + Rectangle().frame(width: currentX) + Spacer(minLength: 0) + } + ) + + // Thumb + Circle() + .fill(Color.white) + .shadow(color: .black.opacity(0.3), radius: 3, x: 0, y: 2) + .frame(width: 20, height: 20) + .offset(x: currentX - 10) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let dragX = max(0, min(value.location.x, width)) + let index = Int(round(dragX / stepWidth)) + let safeIndex = max(0, min(index, weights.count - 1)) + if weights[safeIndex] != weight { + withAnimation(.spring(response: 0.25, dampingFraction: 0.8)) { + weight = weights[safeIndex] + } + } + } + ) + } + .frame(height: 20) + .position(x: geometry.size.width / 2, y: geometry.size.height / 2) + } + .frame(height: 20) + } +} + +struct TrackShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let minHeight: CGFloat = 2 + let maxHeight: CGFloat = 8 + + path.move(to: CGPoint(x: 0, y: rect.midY - minHeight / 2)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY - maxHeight / 2)) + path.addArc(center: CGPoint(x: rect.maxX, y: rect.midY), radius: maxHeight / 2, startAngle: .degrees(-90), endAngle: .degrees(90), clockwise: false) + path.addLine(to: CGPoint(x: 0, y: rect.midY + minHeight / 2)) + path.addArc(center: CGPoint(x: 0, y: rect.midY), radius: minHeight / 2, startAngle: .degrees(90), endAngle: .degrees(270), clockwise: false) + path.closeSubpath() + return path + } +} \ No newline at end of file diff --git a/petalia/utils/VariableBlur.swift b/petalia/utils/VariableBlur.swift new file mode 100644 index 0000000..441f7a9 --- /dev/null +++ b/petalia/utils/VariableBlur.swift @@ -0,0 +1,104 @@ +import SwiftUI +import AppKit +import CoreImage.CIFilterBuiltins +import QuartzCore + +public enum VariableBlurDirection { + case blurredTopClearBottom + case blurredBottomClearTop +} + +public struct VariableBlurView: NSViewRepresentable { + public var maxBlurRadius: CGFloat = 20 + public var direction: VariableBlurDirection = .blurredTopClearBottom + public var startOffset: CGFloat = 0 + + public init(maxBlurRadius: CGFloat = 20, direction: VariableBlurDirection = .blurredTopClearBottom, startOffset: CGFloat = 0) { + self.maxBlurRadius = maxBlurRadius + self.direction = direction + self.startOffset = startOffset + } + + public func makeNSView(context: Context) -> VariableBlurNSView { + VariableBlurNSView(maxBlurRadius: maxBlurRadius, direction: direction, startOffset: startOffset) + } + + public func updateNSView(_ nsView: VariableBlurNSView, context: Context) { + nsView.updateFilters(maxBlurRadius: maxBlurRadius, direction: direction, startOffset: startOffset) + } +} + +open class VariableBlurNSView: NSView { + private var maxBlurRadius: CGFloat + private var direction: VariableBlurDirection + private var startOffset: CGFloat + + public init(maxBlurRadius: CGFloat = 20, direction: VariableBlurDirection = .blurredTopClearBottom, startOffset: CGFloat = 0) { + self.maxBlurRadius = maxBlurRadius + self.direction = direction + self.startOffset = startOffset + super.init(frame: .zero) + + self.wantsLayer = true + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func makeBackingLayer() -> CALayer { + let clsName = "CABackdropLayer" + if let BackdropClass = NSClassFromString(clsName) as? CALayer.Type { + return BackdropClass.init() + } + return super.makeBackingLayer() + } + + open override func updateLayer() { + super.updateLayer() + applyVariableBlur() + } + + public func updateFilters(maxBlurRadius: CGFloat, direction: VariableBlurDirection, startOffset: CGFloat) { + self.maxBlurRadius = maxBlurRadius + self.direction = direction + self.startOffset = startOffset + applyVariableBlur() + } + + private func applyVariableBlur() { + guard let layer = self.layer else { return } + + let clsName = String("retliFAC".reversed()) // CAFilter + guard let Cls = NSClassFromString(clsName)! as? NSObject.Type else { + print("[VariableBlur] Error: Can't find filter class") + return + } + let selName = String(":epyThtiWretlif".reversed()) // filterWithType: + guard let variableBlur = Cls.self.perform(NSSelectorFromString(selName), with: "variableBlur").takeUnretainedValue() as? NSObject else { + print("[VariableBlur] Error: Can't create variableBlur filter") + return + } + + let gradientImage = makeGradientImage(width: 100, height: 100, startOffset: startOffset, direction: direction) + + variableBlur.setValue(maxBlurRadius, forKey: "inputRadius") + variableBlur.setValue(gradientImage, forKey: "inputMaskImage") + variableBlur.setValue(true, forKey: "inputNormalizeEdges") + + layer.filters = [variableBlur] + } + + private func makeGradientImage(width: CGFloat = 100, height: CGFloat = 100, startOffset: CGFloat, direction: VariableBlurDirection) -> CGImage { + let ciGradientFilter = CIFilter.linearGradient() + ciGradientFilter.color0 = CIColor.black + ciGradientFilter.color1 = CIColor.clear + ciGradientFilter.point0 = CGPoint(x: 0, y: height) + ciGradientFilter.point1 = CGPoint(x: 0, y: startOffset * height) + if case .blurredBottomClearTop = direction { + ciGradientFilter.point0.y = 0 + ciGradientFilter.point1.y = height - ciGradientFilter.point1.y + } + return CIContext().createCGImage(ciGradientFilter.outputImage!, from: CGRect(x: 0, y: 0, width: width, height: height))! + } +} diff --git a/scripts/build.sh b/scripts/build.sh index 290b01d..d946a64 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -2,27 +2,31 @@ set -eo pipefail BUILD_DIR="build" -APP_BUNDLE="$BUILD_DIR/moonleaf.app" +APP_BUNDLE="$BUILD_DIR/Petalia.app" CONTENTS_DIR="$APP_BUNDLE/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" RSC_DIR="$CONTENTS_DIR/Resources" BIN_DIR="$RSC_DIR/bin" SAVER_BUILD_DIR="${BUILD_DIR}/saver" -SAVER_DIR="${SAVER_BUILD_DIR}/moonleafSaver.saver" +SAVER_DIR="${SAVER_BUILD_DIR}/PetaliaSaver.saver" +CACHE_DIR=".build-cache" -MP_VER_STRING="v4.0.0" -MP_VER_SHORT_STRING="v4.0" +MP_VER_STRING="v5.0.0" +MP_VER_SHORT_STRING="v5.0" BUILD_ALL=false +CLEAN=false for arg in "$@"; do if [[ "$arg" == "--all" ]]; then BUILD_ALL=true + elif [[ "$arg" == "--clean" ]]; then + CLEAN=true fi done if [[ "$BUILD_ALL" == true ]]; then echo "" - read -p "do you really want to build moonleaf for arm64 and x86_64? it will take longer. (y/N): " -n 1 -r + read -p "do you really want to build Petalia for arm64 and x86_64? it will take longer. (y/N): " -n 1 -r echo "" if [[ ! $REPLY =~ ^[Yy]$ ]]; then echo "cancelled..." @@ -33,9 +37,9 @@ fi # get host arch HOST_ARCH=$(uname -m) if [[ "$HOST_ARCH" == "arm64" ]]; then - HOST_TARGET="arm64-apple-macos12.0" + HOST_TARGET="arm64-apple-macos13.0" elif [[ "$HOST_ARCH" == "x86_64" ]]; then - HOST_TARGET="x86_64-apple-macos12.0" + HOST_TARGET="x86_64-apple-macos13.0" else # useless else block exit 1 @@ -45,107 +49,200 @@ SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")" REPO_ROOT="$(dirname "$SCRIPT_DIR")" cd "$REPO_ROOT" || exit 1 -rm -rf ./build + +# clean build cache if --clean flag is passed +if [[ "$CLEAN" == true ]]; then + echo "cleaning build cache..." + rm -rf "$CACHE_DIR" "$BUILD_DIR" +fi mkdir -p "$MACOS_DIR" mkdir -p "$RSC_DIR" mkdir -p "$BIN_DIR" +# ----------------------------------------------- +# incremental compilation helpers +# ----------------------------------------------- + +# Generate an output-file-map.json that tells swiftc where to put +# .o and .swiftdeps files for each source, enabling incremental builds. +generate_file_map() { + local cache_subdir="$1" + shift + local sources=("$@") + local map_file="$cache_subdir/output-file-map.json" + + { + echo '{' + # master swift-dependencies entry (tracks cross-file deps) + echo ' "": {' + echo " \"swift-dependencies\": \"$cache_subdir/master.swiftdeps\"" + echo ' },' + + local count=${#sources[@]} + local i=0 + for src in "${sources[@]}"; do + i=$((i + 1)) + # derive a unique name from the full path to avoid collisions + # e.g. moonleaf/main/BrowseView.swift -> moonleaf_main_BrowseView + local safe_name + safe_name=$(echo "$src" | tr '/' '_' | sed 's/\.swift$//') + + echo " \"$src\": {" + echo " \"object\": \"$cache_subdir/${safe_name}.o\"," + echo " \"swift-dependencies\": \"$cache_subdir/${safe_name}.swiftdeps\"" + if [[ $i -lt $count ]]; then + echo ' },' + else + echo ' }' + fi + done + + echo '}' + } > "$map_file" +} + +# Incrementally compile Swift sources, then link into a binary. +# +# Usage: +# swift_build \ +# ... -- ... +swift_build() { + local target="$1" + local cache_subdir="$2" + local module_name="$3" + local output="$4" + shift 4 + + local frameworks=() + local sources=() + local past_separator=false + for arg in "$@"; do + if [[ "$arg" == "--" ]]; then + past_separator=true + continue + fi + if [[ "$past_separator" == true ]]; then + sources+=("$arg") + else + frameworks+=("-framework" "$arg") + fi + done + + mkdir -p "$cache_subdir" + + # generate the output file map for this target + arch + generate_file_map "$cache_subdir" "${sources[@]}" + + # phase 1: compile (only changed files get recompiled) + swiftc \ + -suppress-warnings \ + -target "$target" \ + "${frameworks[@]}" \ + -O \ + -incremental \ + -output-file-map "$cache_subdir/output-file-map.json" \ + -module-name "$module_name" \ + -c \ + "${sources[@]}" + + # phase 2: link all object files into the final binary + local objects=() + for src in "${sources[@]}"; do + local safe_name + safe_name=$(echo "$src" | tr '/' '_' | sed 's/\.swift$//') + objects+=("$cache_subdir/${safe_name}.o") + done + + swiftc \ + -target "$target" \ + "${frameworks[@]}" \ + "${objects[@]}" \ + -o "$output" +} + echo "" -echo -e " -     .  -*+-.  ..               -    .*+.#%%%%##=.     .         -   :::..#%%%%%%#:     =.        -   .. -##%%%%%%%%-+:            -     +#####%%%#%%%%#.           -    -#*###%%%%#*%%#%*           -    ***####%%%%#*%%%#           -    **#######%%%##%%#--         -    -########%%%%*#%%%%.   -    -     =########%%%%*%%%%==*#-    -    - -*#######%%%##%%%##*:--   -    .   -*######%%#*%%#+::.=-   -          .:=++*****+:          -                   =            -                   -           " +echo " + ร—ร—ร—ร—ร—ร—รท + ร—ร—ร—ร—ร—ร—ร—ร—ร—ร—รทรทรทรทร—ร—ร—ร—ร—ร—รท + ร—ร—ร—ร—รทร—ร—ร—ร—รทรทรทรทรทรทร—ร—ร—รทรทรทรทรท + ร—ร—รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + ร—ร—ร—ร—ร—ร—ร—รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทร—ร—ร—ร—ร—รท + รทร—ร—ร—รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรทรทรทรท รทรทรทรทรท รทรทรทรทรทรทรทรทรทรทรทรท +ร—ร—ร—ร—ร—รทรทรทรทรทรท= รทรทรท รทรทรทรทรทรทรทรทรทรทรทรทรทรท +รทร—ร—รทรทรทรทรทรทรทรทรท รทรทรทรทรท รทรทรทรทรทรทรทรทรทรทรทรทรทรท +รทรทรทรทรทรทรทรทรทรทรทรทรท ร—ร—ร—รทรทรทรท ร—ร—ร—รทรทรทรทรทรทรทรทรทรทรทรท +รทรทรทรทรทรทรทรทรทรทรทรทร—ร—ร—ร—รทรทรทรทร—ร—ร—ร—รทรทรทรทรทรทรทรทรทรทรทรท= + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทร—รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท= + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรทรท + รทรทรทรทรทรทรท + +" echo "####################################################" -echo " building moonleaf ${MP_VER_STRING} to ${BUILD_DIR}" +echo " building Petalia ${MP_VER_STRING} to ${BUILD_DIR}" echo "####################################################" echo "macOS $(sw_vers -productVersion), arch: $HOST_ARCH" +# ----------------------------------------------- +# Petalia (Swift, incremental) +# ----------------------------------------------- echo "" -echo "-- moonleaf --" -if [[ "$BUILD_ALL" == true ]]; then - swiftc \ - -suppress-warnings \ - -target x86_64-apple-macos12.0 \ - -framework SwiftUI -framework AppKit -framework AVKit \ - -framework AVFoundation -framework UniformTypeIdentifiers \ - -framework Combine -O \ - moonleaf/main/*.swift \ - moonleaf/utils/*.swift \ - -o "$MACOS_DIR/macpaper_amd64" +echo "-- Petalia --" - echo "compiled moonleaf (amd64) (1/3)" +PETALIA_SOURCES=(petalia/main/*.swift petalia/utils/*.swift) +PETALIA_FRAMEWORKS=(SwiftUI AppKit AVKit AVFoundation UniformTypeIdentifiers Combine) - swiftc \ - -suppress-warnings \ - -target arm64-apple-macos12.0 \ - -framework SwiftUI -framework AppKit -framework AVKit \ - -framework AVFoundation -framework UniformTypeIdentifiers \ - -framework Combine -O \ - moonleaf/main/*.swift \ - moonleaf/utils/*.swift \ - -o "$MACOS_DIR/macpaper_arm64" +if [[ "$BUILD_ALL" == true ]]; then + swift_build "x86_64-apple-macos13.0" "$CACHE_DIR/petalia-amd64" "petalia" \ + "$MACOS_DIR/macpaper_amd64" \ + "${PETALIA_FRAMEWORKS[@]}" -- "${PETALIA_SOURCES[@]}" + echo "compiled Petalia (amd64) (1/3)" - echo "compiled moonleaf (arm64) (2/3)" + swift_build "arm64-apple-macos13.0" "$CACHE_DIR/petalia-arm64" "petalia" \ + "$MACOS_DIR/macpaper_arm64" \ + "${PETALIA_FRAMEWORKS[@]}" -- "${PETALIA_SOURCES[@]}" + echo "compiled Petalia (arm64) (2/3)" lipo -create \ "$MACOS_DIR/macpaper_amd64" \ "$MACOS_DIR/macpaper_arm64" \ - -o "$MACOS_DIR/moonleaf" + -o "$MACOS_DIR/petalia" - echo "compiled moonleaf (universal) (3/3)" + echo "compiled Petalia (universal) (3/3)" rm "$MACOS_DIR/macpaper_amd64" "$MACOS_DIR/macpaper_arm64" else - swiftc \ - -suppress-warnings \ - -target "$HOST_TARGET" \ - -framework SwiftUI -framework AppKit -framework AVKit \ - -framework AVFoundation -framework UniformTypeIdentifiers \ - -framework Combine -O \ - moonleaf/main/*.swift \ - moonleaf/utils/*.swift \ - -o "$MACOS_DIR/moonleaf" - - echo "compiled moonleaf ($HOST_ARCH)" + swift_build "$HOST_TARGET" "$CACHE_DIR/petalia-$HOST_ARCH" "petalia" \ + "$MACOS_DIR/petalia" \ + "${PETALIA_FRAMEWORKS[@]}" -- "${PETALIA_SOURCES[@]}" + echo "compiled Petalia ($HOST_ARCH)" fi echo "" +# ----------------------------------------------- +# glasswp (Swift, incremental) +# ----------------------------------------------- echo "" -echo "-- moonleaf Animated Wallpaper Engine (glasswp) --" +echo "-- Petalia Animated Wallpaper Engine (glasswp) --" -if [[ "$BUILD_ALL" == true ]]; then - swiftc \ - -suppress-warnings \ - -target x86_64-apple-macos12.0 \ - -framework AppKit -framework AVFoundation \ - -framework MediaToolbox -framework Accelerate \ - -O \ - glasswp/glasswp.swift \ - -o "$MACOS_DIR/glasswp_amd64" +GLASSWP_SOURCES=(glasswp/glasswp.swift) +GLASSWP_FRAMEWORKS=(AppKit AVFoundation MediaToolbox Accelerate) +if [[ "$BUILD_ALL" == true ]]; then + swift_build "x86_64-apple-macos13.0" "$CACHE_DIR/glasswp-amd64" "glasswp" \ + "$MACOS_DIR/glasswp_amd64" \ + "${GLASSWP_FRAMEWORKS[@]}" -- "${GLASSWP_SOURCES[@]}" echo "compiled glasswp (amd64) (1/3)" - swiftc \ - -suppress-warnings \ - -target arm64-apple-macos12.0 \ - -framework AppKit -framework AVFoundation \ - -framework MediaToolbox -framework Accelerate \ - -O \ - glasswp/glasswp.swift \ - -o "$MACOS_DIR/glasswp_arm64" - + swift_build "arm64-apple-macos13.0" "$CACHE_DIR/glasswp-arm64" "glasswp" \ + "$MACOS_DIR/glasswp_arm64" \ + "${GLASSWP_FRAMEWORKS[@]}" -- "${GLASSWP_SOURCES[@]}" echo "compiled glasswp (arm64) (2/3)" lipo -create "$MACOS_DIR/glasswp_amd64" "$MACOS_DIR/glasswp_arm64" \ @@ -154,55 +251,55 @@ if [[ "$BUILD_ALL" == true ]]; then echo "compiled glasswp (universal) (3/3)" rm "$MACOS_DIR/glasswp_amd64" "$MACOS_DIR/glasswp_arm64" else - swiftc \ - -suppress-warnings \ - -target "$HOST_TARGET" \ - -framework AppKit -framework AVFoundation \ - -framework MediaToolbox -framework Accelerate \ - -O \ - glasswp/glasswp.swift \ - -o "$BIN_DIR/glasswp" - + swift_build "$HOST_TARGET" "$CACHE_DIR/glasswp-$HOST_ARCH" "glasswp" \ + "$BIN_DIR/glasswp" \ + "${GLASSWP_FRAMEWORKS[@]}" -- "${GLASSWP_SOURCES[@]}" echo "compiled glasswp ($HOST_ARCH)" fi echo "" -echo "-- moonleaf-bin --" +# ----------------------------------------------- +# petalia-bin (C, single file โ€” no incremental needed) +# ----------------------------------------------- +echo "-- petalia-bin --" if [[ "$BUILD_ALL" == true ]]; then - gcc -target x86_64-apple-macos12.0 \ - moonleaf/obj/moonleaf.c -o "$MACOS_DIR/moonleaf-bin_amd64" + gcc -target x86_64-apple-macos13.0 \ + petalia/obj/petalia.c -o "$MACOS_DIR/petalia-bin_amd64" - gcc -target arm64-apple-macos12.0 \ - moonleaf/obj/moonleaf.c -o "$MACOS_DIR/moonleaf-bin_arm64" + gcc -target arm64-apple-macos13.0 \ + petalia/obj/petalia.c -o "$MACOS_DIR/petalia-bin_arm64" - lipo -create "$MACOS_DIR/moonleaf-bin_amd64" "$MACOS_DIR/moonleaf-bin_arm64" \ - -o "$MACOS_DIR/moonleaf-bin" + lipo -create "$MACOS_DIR/petalia-bin_amd64" "$MACOS_DIR/petalia-bin_arm64" \ + -o "$MACOS_DIR/petalia-bin" - echo "compiled moonleaf-bin (universal)" - rm "$MACOS_DIR/moonleaf-bin_amd64" "$MACOS_DIR/moonleaf-bin_arm64" + echo "compiled petalia-bin (universal)" + rm "$MACOS_DIR/petalia-bin_amd64" "$MACOS_DIR/petalia-bin_arm64" else gcc -target "$HOST_TARGET" \ - moonleaf/obj/moonleaf.c -o "$MACOS_DIR/moonleaf-bin" + petalia/obj/petalia.c -o "$MACOS_DIR/petalia-bin" - echo "compiled moonleaf-bin ($HOST_ARCH)" + echo "compiled petalia-bin ($HOST_ARCH)" fi echo "" -echo "adding moonleaf Info.plist" +# ----------------------------------------------- +# bundle app resources +# ----------------------------------------------- +echo "adding petalia Info.plist" cat > "$CONTENTS_DIR/Info.plist" << EOF CFBundleExecutable - moonleaf + petalia CFBundleIdentifier - com.naomisphere.macpaper + com.naomisphere.petalia CFBundleName - moonleaf + Petalia CFBundleDisplayName - moonleaf + Petalia CFBundlePackageType APPL CFBundleShortVersionString @@ -210,9 +307,9 @@ cat > "$CONTENTS_DIR/Info.plist" << EOF CFBundleVersion ${MP_VER_STRING} CFBundleIconFile - moonleaf.icns + Petalia.icns LSMinimumSystemVersion - 12.0 + 13.0 LSUIElement NSHighResolutionCapable @@ -229,19 +326,31 @@ EOF echo "" echo "-- bundling app resources --" -cp artwork/icns/moonleaf/moonleaf.icns "$RSC_DIR" 2>/dev/null || true -cp artwork/png/moonleaf.png "${RSC_DIR}/.moonleaf_logo.png" 2>/dev/null || true -cp artwork/png/moonleaf.png "${RSC_DIR}/StatusBarIcon.png" 2>/dev/null || true +cp artwork/icns/Petalia/Petalia.icns "$RSC_DIR" 2>/dev/null || true +cp artwork/png/petalia.png "${RSC_DIR}/.petalia_logo.png" 2>/dev/null || true +cp artwork/png/petalia.png "${RSC_DIR}/StatusBarIcon.png" 2>/dev/null || true cp img/png/kofi_symbol.png "$RSC_DIR/.kofi.png" 2>/dev/null || true -gzip -dc moonleaf/resources/bin/wallpaper.gz > "moonleaf/resources/bin/wallpaper" 2>/dev/null -cp -R moonleaf/resources/* "$RSC_DIR/" 2>/dev/null || true +gzip -dc petalia/resources/bin/wallpaper.gz > "petalia/resources/bin/wallpaper" 2>/dev/null +chmod +x "petalia/resources/bin/wallpaper" 2>/dev/null || true +cp -R petalia/resources/* "$RSC_DIR/" 2>/dev/null || true rm -f "$RSC_DIR/bin/wallpaper.gz" 2>/dev/null +chmod +x "$BIN_DIR/wallpaper" 2>/dev/null || true echo "adding localization strings" cp -r lang/*.lproj "$RSC_DIR" +if [[ -d "petalia/Assets.xcassets" ]]; then + echo "compiling asset catalog..." + actool petalia/Assets.xcassets \ + --compile "$RSC_DIR" \ + --platform macosx \ + --minimum-deployment-target 13.0 \ + --output-format xml1 > /dev/null +fi + + echo "" -echo "done! moonleaf ${MP_VER_STRING} is at ${BUILD_DIR}/moonleaf.app" +echo "done! Petalia ${MP_VER_STRING} is at ${BUILD_DIR}/Petalia.app" echo "glasswp installed to: $BIN_DIR/glasswp" echo "" diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..ee52b60 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "skills": { + "swiftui-animation": { + "source": "dpearson2699/swift-ios-skills", + "sourceType": "github", + "skillPath": "skills/swiftui-animation/SKILL.md", + "computedHash": "d31316ceb76e3e1c109442859f25de8879b486560b549b081b4f87124c8c7a03" + }, + "swiftui-expert-skill": { + "source": "avdlee/swiftui-agent-skill", + "sourceType": "github", + "skillPath": "swiftui-expert-skill/SKILL.md", + "computedHash": "12a318304d7cdc6ec40e981f02865d098caf6eab1b992051e3322ebeecf287b1" + }, + "swiftui-pro": { + "source": "twostraws/swiftui-agent-skill", + "sourceType": "github", + "skillPath": "swiftui-pro/SKILL.md", + "computedHash": "07033426e384295a4b49cf0b2ffdefd4098cae4af53fef16bc1f2d9281118c41" + } + } +} diff --git a/test_parse.swift b/test_parse.swift new file mode 100644 index 0000000..b93a074 --- /dev/null +++ b/test_parse.swift @@ -0,0 +1,52 @@ +import Foundation + +let jsonString = """ +{"data":[{"id":"xed7wo","url":"https://wallhaven.cc/w/xed7wo","short_url":"https://whvn.cc/xed7wo","views":14,"favorites":2,"source":"","purity":"sfw","category":"people","dimension_x":3200,"dimension_y":2217,"resolution":"3200x2217","ratio":"1.44","file_size":2722317,"file_type":"image/jpeg","created_at":"2026-07-01 10:37:36","colors":["#424153","#000000","#cccccc","#663399","#999999"],"path":"https://w.wallhaven.cc/full/xe/wallhaven-xed7wo.jpg","thumbs":{"large":"https://th.wallhaven.cc/lg/xe/xed7wo.jpg","original":"https://th.wallhaven.cc/orig/xe/xed7wo.jpg","small":"https://th.wallhaven.cc/small/xe/xed7wo.jpg"}}],"meta":{"current_page":1,"last_page":25814,"per_page":24,"total":619513,"query":null,"seed":null}} +""" + +struct WHResponse: Codable { + let data: [WHWallpaper] + let meta: WHMeta +} +struct WHWallpaper: Codable { + let id: String + let url: String + let short_url: String + let views: Int + let favorites: Int + let source: String + let purity: String + let category: String + let dimension_x: Int + let dimension_y: Int + let resolution: String + let ratio: String + let file_size: Int + let file_type: String + let created_at: String + let colors: [String] + let path: String + let thumbs: WHThumbs +} +struct WHThumbs: Codable { + let large: String + let original: String + let small: String +} +struct WHMeta: Codable { + let current_page: Int + let last_page: Int + let per_page: Int + let total: Int + let query: String? + let seed: String? +} + +do { + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let response = try decoder.decode(WHResponse.self, from: data) + print("Success: \(response.data.count) wallpapers") +} catch { + print("Error: \(error)") +}