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
@@ -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 "
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;59m-[0m[38;5;145m*[0m[38;5;102m+[0m[38;5;59m-[0m[38;5;17m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m.[0m[38;5;17m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m.[0m[38;5;109m*[0m[38;5;103m+[0m[38;5;0m.[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;66m=[0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;59m:[0m[38;5;59m:[0m[38;5;59m:[0m[38;5;0m.[0m[38;5;0m.[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;146m#[0m[38;5;59m:[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;60m=[0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;17m.[0m[38;5;17m.[0m[38;5;0m [0m[38;5;60m-[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;59m-[0m[38;5;103m+[0m[38;5;59m:[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;103m+[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;188m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m#[0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;60m-[0m[38;5;146m#[0m[38;5;146m*[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;182m#[0m[38;5;146m*[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m#[0m[38;5;188m%[0m[38;5;103m*[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;110m*[0m[38;5;146m*[0m[38;5;146m*[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;182m#[0m[38;5;146m*[0m[38;5;189m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;182m#[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;104m*[0m[38;5;146m*[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;189m%[0m[38;5;188m%[0m[38;5;182m#[0m[38;5;59m-[0m[38;5;59m-[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;59m-[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;146m*[0m[38;5;182m#[0m[38;5;189m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;59m-[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;66m=[0m[38;5;147m#[0m[38;5;147m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;147m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;188m%[0m[38;5;146m*[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;66m=[0m[38;5;60m=[0m[38;5;110m*[0m[38;5;146m#[0m[38;5;59m-[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;59m-[0m[38;5;0m [0m[38;5;59m-[0m[38;5;146m*[0m[38;5;147m#[0m[38;5;147m#[0m[38;5;147m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;146m#[0m[38;5;146m#[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;188m%[0m[38;5;147m#[0m[38;5;147m#[0m[38;5;110m*[0m[38;5;59m:[0m[38;5;59m-[0m[38;5;59m-[0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m.[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;59m-[0m[38;5;103m*[0m[38;5;146m#[0m[38;5;153m#[0m[38;5;153m#[0m[38;5;153m#[0m[38;5;153m#[0m[38;5;153m#[0m[38;5;188m%[0m[38;5;189m%[0m[38;5;182m#[0m[38;5;146m*[0m[38;5;189m%[0m[38;5;188m%[0m[38;5;146m#[0m[38;5;103m+[0m[38;5;59m:[0m[38;5;59m:[0m[38;5;17m.[0m[38;5;60m=[0m[38;5;59m-[0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m.[0m[38;5;59m:[0m[38;5;60m=[0m[38;5;103m+[0m[38;5;103m+[0m[38;5;109m*[0m[38;5;110m*[0m[38;5;145m*[0m[38;5;103m*[0m[38;5;139m*[0m[38;5;102m+[0m[38;5;59m:[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;60m=[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m
- [38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;60m-[0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m[38;5;0m [0m"
+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)")
+}