66package app.morphe.gui
77
88import androidx.compose.animation.Crossfade
9- import androidx.compose.foundation.layout.fillMaxSize
9+ import androidx.compose.foundation.background
10+ import androidx.compose.foundation.layout.*
11+ import androidx.compose.foundation.window.WindowDraggableArea
12+ import androidx.compose.material3.MaterialTheme
1013import androidx.compose.material3.Surface
1114import androidx.compose.runtime.*
15+ import androidx.compose.ui.Alignment
1216import androidx.compose.ui.Modifier
17+ import androidx.compose.ui.draw.clipToBounds
18+ import androidx.compose.ui.unit.dp
19+ import app.morphe.gui.ui.components.LocalFrameWindowScope
20+ import app.morphe.gui.ui.components.LottieAnimation
21+ import app.morphe.gui.ui.components.SakuraPetals
22+ import app.morphe.gui.util.applyTitleBarTint
1323import cafe.adriel.voyager.navigator.Navigator
1424import cafe.adriel.voyager.transitions.SlideTransition
1525import app.morphe.gui.data.repository.ConfigRepository
16- import app.morphe.gui.data.repository.PatchRepository
17- import app.morphe.gui.util.PatchService
26+ import app.morphe.gui.data.repository.PatchSourceManager
1827import app.morphe.gui.di.appModule
1928import kotlinx.coroutines.launch
2029import org.koin.compose.KoinApplication
2130import org.koin.compose.koinInject
2231import app.morphe.gui.ui.screens.home.HomeScreen
2332import app.morphe.gui.ui.screens.quick.QuickPatchContent
2433import app.morphe.gui.ui.screens.quick.QuickPatchViewModel
34+ import app.morphe.gui.util.PatchService
2535import app.morphe.gui.ui.theme.LocalThemeState
2636import app.morphe.gui.ui.theme.MorpheTheme
2737import app.morphe.gui.ui.theme.ThemePreference
@@ -42,31 +52,35 @@ val LocalModeState = staticCompositionLocalOf<ModeState> {
4252}
4353
4454@Composable
45- fun App (initialSimplifiedMode : Boolean = true) {
55+ fun App (
56+ initialSimplifiedMode : Boolean = true
57+ ) {
4658 LaunchedEffect (Unit ) {
4759 Logger .init ()
4860 }
4961
5062 KoinApplication (application = {
5163 modules(appModule)
5264 }) {
53- AppContent (initialSimplifiedMode)
65+ AppContent (initialSimplifiedMode = initialSimplifiedMode )
5466 }
5567}
5668
5769@Composable
58- private fun AppContent (initialSimplifiedMode : Boolean ) {
70+ private fun AppContent (
71+ initialSimplifiedMode : Boolean
72+ ) {
5973 val configRepository: ConfigRepository = koinInject()
60- val patchRepository: PatchRepository = koinInject()
61- val patchService: PatchService = koinInject()
74+ val patchSourceManager: PatchSourceManager = koinInject()
6275 val scope = rememberCoroutineScope()
6376
6477 var themePreference by remember { mutableStateOf(ThemePreference .SYSTEM ) }
6578 var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) }
6679 var isLoading by remember { mutableStateOf(true ) }
6780
68- // Load config on startup
81+ // Initialize PatchSourceManager and load config on startup
6982 LaunchedEffect (Unit ) {
83+ patchSourceManager.initialize()
7084 val config = configRepository.loadConfig()
7185 themePreference = config.getThemePreference()
7286 isSimplifiedMode = config.useSimplifiedMode
@@ -114,25 +128,97 @@ private fun AppContent(initialSimplifiedMode: Boolean) {
114128 LocalThemeState provides themeState,
115129 LocalModeState provides modeState
116130 ) {
131+ // Tint the OS title bar (Windows DWM caption color, macOS traffic
132+ // light contrast) to match the active theme's surface color.
133+ val titleBarColor = MaterialTheme .colorScheme.surface
134+ val frameScope = LocalFrameWindowScope .current
135+ LaunchedEffect (titleBarColor, frameScope) {
136+ frameScope?.window?.let { applyTitleBarTint(it, titleBarColor) }
137+ }
138+
139+ // macOS only: render a 28dp colored band at the very top of the
140+ // window, sitting underneath the (now-transparent) OS title bar.
141+ // The traffic lights overlay this band at their default position.
142+ // Wrapped in WindowDraggableArea so the band acts as a drag region.
143+ val isMac = remember {
144+ System .getProperty(" os.name" )?.lowercase()?.contains(" mac" ) == true
145+ }
146+
117147 Surface (modifier = Modifier .fillMaxSize()) {
118- if (! isLoading) {
119- // Create QuickPatchViewModel outside Crossfade so it persists across mode switches.
120- // Otherwise every expert→simplified switch creates a new VM that re-fetches from GitHub.
121- val quickViewModel = remember {
122- QuickPatchViewModel (patchRepository, patchService, configRepository)
148+ Column (modifier = Modifier .fillMaxSize()) {
149+ if (isMac && frameScope != null ) {
150+ with (frameScope) {
151+ WindowDraggableArea {
152+ Box (
153+ modifier = Modifier
154+ .fillMaxWidth()
155+ .height(16 .dp)
156+ .background(titleBarColor)
157+ )
158+ }
159+ }
123160 }
124161
125- Crossfade (targetState = isSimplifiedMode) { simplified ->
126- if (simplified) {
127- // Quick/Simplified mode
128- QuickPatchContent (quickViewModel)
129- } else {
130- // Full mode
131- Navigator (HomeScreen ()) { navigator ->
132- SlideTransition (navigator)
162+ Box (modifier = Modifier .fillMaxWidth().weight(1f )) {
163+ if (! isLoading) {
164+ val patchService: PatchService = koinInject()
165+ val quickViewModel = remember {
166+ QuickPatchViewModel (patchSourceManager, patchService, configRepository)
167+ }
168+
169+ Crossfade (targetState = isSimplifiedMode) { simplified ->
170+ if (simplified) {
171+ QuickPatchContent (quickViewModel)
172+ } else {
173+ Navigator (HomeScreen ()) { navigator ->
174+ SlideTransition (navigator)
175+ }
176+ }
177+ }
178+ }
179+
180+ // Falling petals — on top of everything (Sakura)
181+ SakuraPetals (
182+ enabled = themePreference == ThemePreference .SAKURA
183+ )
184+
185+ // Matcha cat — top-right corner
186+ if (themePreference == ThemePreference .MATCHA ) {
187+ val catJson = remember {
188+ try {
189+ object {}.javaClass.getResourceAsStream(" /cat2333s.json" )
190+ ?.bufferedReader()?.readText()
191+ } catch (e: Exception ) {
192+ null
193+ }
194+ }
195+ catJson?.let { json ->
196+ // 1080px canvas, rendered at 350dp (1dp ≈ 3.086 canvas px).
197+ // Ears ~y385 → 125dp, bar bottom ~y576 → 187dp.
198+ // Body shrunk to 85% so it hides behind bar.
199+ // Clip from 120dp to 192dp (72dp visible) — ears to just past bar.
200+ val renderSize = 350 .dp
201+ val clipTop = 120 .dp // just above ears
202+ val clipHeight = 72 .dp // ears → just past bar bottom
203+ Box (
204+ modifier = Modifier
205+ .align(Alignment .TopEnd )
206+ .padding(top = 24 .dp, end = 16 .dp)
207+ .requiredWidth(renderSize)
208+ .requiredHeight(clipHeight)
209+ .clipToBounds()
210+ ) {
211+ LottieAnimation (
212+ jsonString = json,
213+ modifier = Modifier
214+ .requiredSize(renderSize)
215+ .offset(y = - clipTop),
216+ alpha = 0.28f
217+ )
133218 }
134219 }
135220 }
221+ }
136222 }
137223 }
138224 }
0 commit comments