Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build_pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ jobs:
with:
fetch-depth: 0

- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '17'
java-version: '21'

- name: Cache Gradle
uses: burrunan/gradle-cache-action@v1
Expand Down
4 changes: 2 additions & 2 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ group = "app.morphe"
// ============================================================================
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(17))
languageVersion.set(JavaLanguageVersion.of(21))
vendor.set(JvmVendorSpec.JETBRAINS)
}
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
jvmTarget.set(JvmTarget.JVM_21)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ fun Set<Patch<*>>.toPatchBundle(
.filter { it.name != null }
.associate { patch ->
patch.name!! to PatchEntry(
enabled = patch.use,
enabled = patch.default,
options = patch.options.mapValues { (_, option) ->
PatchSerializer.serializeValue(option.default)
},
Expand Down Expand Up @@ -140,7 +140,7 @@ fun Set<Patch<*>>.mergeWithBundle(
}

patchName to PatchEntry(
enabled = existingEntry?.enabled ?: patch.use,
enabled = existingEntry?.enabled ?: patch.default,
options = updatedOptions,
)
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/kotlin/app/morphe/engine/MorpheData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import java.util.logging.Logger
* morphe-data/
* patches/{owner}-{repo}/v1.5.0__patches.mpp # downloaded .mpp files
* logs/ # app logs
* icons/{packageName}/ # user-created custom app icons (persistent)
* config.json # GUI preferences + sources
* tmp/patching-{timestamp}/ # per-session patcher scratch
* morphe.keystore # shared default signing key
Expand Down Expand Up @@ -70,6 +71,10 @@ object MorpheData {
/** App logs. */
val logsDir: File by lazy { File(root, "logs").also { it.mkdirs() } }

/** User-created custom app icons (Icon Studio output), organized per package.
* Persistent USER CONTENT. Deliberately NOT wiped by clear-cache. */
val iconsDir: File by lazy { File(root, "icons").also { it.mkdirs() } }

/** Patcher scratch space. Each patching session gets its own subfolder
* here (see Phase 6 of the unified-data-location plan). */
val tmpDir: File by lazy { File(root, "tmp").also { it.mkdirs() } }
Expand Down
44 changes: 44 additions & 0 deletions src/main/kotlin/app/morphe/gui/icon/CustomSwatches.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-desktop
*/

package app.morphe.gui.icon

import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import app.morphe.engine.MorpheData
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File

/**
* User-saved colours, shared across every colour control in the Icon Studio and
* persisted to `morphe-data/icons/swatches.json`. Backed by a snapshot list so the
* UI recomposes when a colour is added/removed. Capped at [MAX] slots.
*/
object CustomSwatches {
const val MAX = 12

private val file by lazy { File(MorpheData.iconsDir, "swatches.json") }
private val json = Json { ignoreUnknownKeys = true }

val colors: SnapshotStateList<Int> = mutableStateListOf<Int>().also { list ->
runCatching { if (file.exists()) list.addAll(json.decodeFromString<List<Int>>(file.readText())) }
}

val isFull: Boolean get() = colors.size >= MAX

fun add(argb: Int) {
if (argb !in colors && colors.size < MAX) { colors.add(argb); save() }
}

fun remove(argb: Int) {
if (colors.remove(argb)) save()
}

private fun save() {
runCatching { file.writeText(json.encodeToString(colors.toList())) }
}
}
48 changes: 48 additions & 0 deletions src/main/kotlin/app/morphe/gui/icon/ForegroundPrep.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-desktop
*/

package app.morphe.gui.icon

import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO

/**
* Handles an image being imported as a FOREGROUND layer.
*
* Foreground logos should have transparency (so the monochrome / notification icons
* read as a shape, not a solid block). We deliberately DON'T modify the image. Auto
* background-removal could mangle a real icon, so we just copy it as-is and report whether
* it has transparency so the UI can warn the user when it doesn't.
*/
object ForegroundPrep {

enum class Outcome { ALREADY_TRANSPARENT, OPAQUE, UNREADABLE }

data class Prepared(val path: String, val outcome: Outcome)

fun prepare(src: File, destDir: File): Prepared {
destDir.mkdirs()
val dest = File(destDir, "${System.nanoTime()}-${src.name}")
runCatching { src.copyTo(dest, overwrite = true) }

val img = runCatching { ImageIO.read(src) }.getOrNull()
val outcome = when {
img == null -> Outcome.UNREADABLE
hasTransparency(img) -> Outcome.ALREADY_TRANSPARENT
else -> Outcome.OPAQUE
}
return Prepared(dest.absolutePath, outcome)
}

private fun hasTransparency(img: BufferedImage): Boolean {
if (!img.colorModel.hasAlpha()) return false
val w = img.width; val h = img.height
val px = IntArray(w * h); img.getRGB(0, 0, w, h, px, 0, w)
var transparent = 0
for (p in px) if (((p ushr 24) and 0xFF) < 250) transparent++
return transparent > w * h * 0.01f // >1% see-through → a real cut-out
}
}
111 changes: 111 additions & 0 deletions src/main/kotlin/app/morphe/gui/icon/IconExporter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-desktop
*/

package app.morphe.gui.icon

import app.morphe.engine.MorpheData
import java.io.File
import javax.imageio.ImageIO

/**
* Flattens an [IconProject] into the exact folder the branding patch's `customIcon`
* option reads. Mirrors morphe-manager's `AdaptiveIconConfig` constants:
* - `mipmap-<dpi>/{background,foreground}.png` — the adaptive icon (required)
* - `drawable-<dpi>/notification.png` — status-bar notification icon
* - `drawable/monochrome.xml` — Android 13+ themed-icon layer
*
* Output lives under `morphe-data/icons/<packageName>/generated/` — persistent user
* content, deliberately NOT part of clear-cache.
*/
object IconExporter {

private const val BACKGROUND_FILE = "morphe_adaptive_background_custom.png"
private const val FOREGROUND_FILE = "morphe_adaptive_foreground_custom.png"
private const val NOTIFICATION_FILE = "morphe_notification_icon_custom.png"
private const val MONOCHROME_FILE = "morphe_adaptive_monochrome_custom.xml"
private const val MONO_VIEWPORT = 108
private const val SAFE_ZONE = 0.72f // notification-icon content fraction (padding, like the manager)

private val DENSITIES = listOf(
"mipmap-mdpi" to 108, "mipmap-hdpi" to 162, "mipmap-xhdpi" to 216,
"mipmap-xxhdpi" to 324, "mipmap-xxxhdpi" to 432,
)
private val NOTIFICATION_DENSITIES = listOf(
"drawable-mdpi" to 24, "drawable-hdpi" to 36, "drawable-xhdpi" to 48,
"drawable-xxhdpi" to 72, "drawable-xxxhdpi" to 96,
)

/** Per-package project directory: `morphe-data/icons/<packageName>/`. */
fun projectDir(packageName: String): File =
File(MorpheData.iconsDir, packageName).also { it.mkdirs() }

/**
* Render [project] at all densities and write the mipmap folder (+ notification
* icon + monochrome layer) for [packageName]. Returns the folder to set as the
* `customIcon` option value.
*/
fun export(project: IconProject, packageName: String): File {
val generated = File(projectDir(packageName), "generated")
generated.deleteRecursively()
generated.mkdirs()

// Adaptive icon: separate background + foreground per density.
for ((folder, size) in DENSITIES) {
val dir = File(generated, folder).also { it.mkdirs() }
ImageIO.write(IconRenderer.renderBackground(project, size), "png", File(dir, BACKGROUND_FILE))
ImageIO.write(IconRenderer.renderForeground(project, size), "png", File(dir, FOREGROUND_FILE))
}

// Notification icon: white foreground silhouette, fitted to the safe zone so the
// small status-bar glyph has padding and isn't clipped.
for ((folder, size) in NOTIFICATION_DENSITIES) {
val dir = File(generated, folder).also { it.mkdirs() }
ImageIO.write(IconRenderer.renderSilhouette(project, size, 0xFFFFFFFF.toInt(), SAFE_ZONE), "png", File(dir, NOTIFICATION_FILE))
}

// Monochrome themed-icon layer: a VectorDrawable of the foreground silhouette.
val drawableDir = File(generated, "drawable").also { it.mkdirs() }
File(drawableDir, MONOCHROME_FILE).writeText(monochromeXml(project))

return generated
}

private fun monochromeXml(project: IconProject): String {
val silhouette = IconRenderer.renderSilhouette(project, MONO_VIEWPORT, 0xFF000000.toInt())
val path = silhouetteToPath(silhouette)
return """<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="${MONO_VIEWPORT}dp"
android:height="${MONO_VIEWPORT}dp"
android:viewportWidth="$MONO_VIEWPORT"
android:viewportHeight="$MONO_VIEWPORT">
<path
android:fillColor="#FF000000"
android:pathData="$path" />
</vector>"""
}

/** Vectorise an opaque silhouette into VectorDrawable path data — one rectangle
* per horizontal run of opaque pixels (keeps the path compact). */
private fun silhouetteToPath(img: java.awt.image.BufferedImage): String {
val w = img.width; val h = img.height
val px = IntArray(w * h); img.getRGB(0, 0, w, h, px, 0, w)
val sb = StringBuilder()
for (y in 0 until h) {
var x = 0
while (x < w) {
if (((px[y * w + x] ushr 24) and 0xFF) > 128) {
val start = x
while (x < w && ((px[y * w + x] ushr 24) and 0xFF) > 128) x++
val run = x - start
sb.append("M").append(start).append(",").append(y).append("h").append(run).append("v1h-").append(run).append("z")
} else {
x++
}
}
}
return sb.toString()
}
}
119 changes: 119 additions & 0 deletions src/main/kotlin/app/morphe/gui/icon/IconProject.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright 2026 Morphe.
* https://github.com/MorpheApp/morphe-desktop
*/

package app.morphe.gui.icon

import kotlinx.serialization.Serializable

/**
* The editable state of a custom adaptive app icon.
*
* Adaptive icons are TWO layers the launcher composites and masks itself: a
* [background] that fills the tile, and a foreground (positioned inside the safe
* zone). Here the foreground is a **stack of [layers]** — images, text, and
* shapes — composited bottom-to-top. The stack is flattened into the single
* foreground PNG on export; [background] is exported separately.
*
* [Serializable] so a project can be saved to `project.json` and reopened.
*/
@Serializable
data class IconProject(
val background: Background = Background.Solid(0xFFFFFFFF.toInt()),
val layers: List<Layer> = emptyList(),
) {

/** What fills the icon tile behind the foreground. */
@Serializable
sealed interface Background {
@Serializable
data class Solid(val argb: Int) : Background

/** Multi-stop gradient. [angleDeg] applies to LINEAR (0 = →, 90 = ↓) and CONIC. */
@Serializable
data class Gradient(
val stops: List<Stop> = listOf(Stop(0f, 0xFF00E5FF.toInt()), Stop(1f, 0xFF000000.toInt())),
val type: GradientType = GradientType.LINEAR,
val angleDeg: Float = 45f,
) : Background

/** A gradient colour stop at [position] (0..1) along the gradient. */
@Serializable
data class Stop(val position: Float, val argb: Int)

@Serializable
data class Image(val sourcePath: String) : Background
}

@Serializable
enum class GradientType { LINEAR, RADIAL, CONIC }

/**
* One foreground element with its own transform, colour adjust and effects.
* [content] is the element (image/text/shape); everything else is applied
* uniformly regardless of element type. Transform is in tile-fraction units.
*/
@Serializable
data class Layer(
val id: String,
val content: LayerContent,
val name: String? = null,
val opacity: Float = 1f,
val scale: Float = 1f,
val offsetX: Float = 0f,
val offsetY: Float = 0f,
val rotationDeg: Float = 0f,
val hueShiftDeg: Float = 0f,
val saturation: Float = 1f,
val brightness: Float = 1f,
val shadow: Shadow? = null,
val glow: Glow? = null,
val stroke: Stroke? = null,
) {
val label: String
get() = name ?: when (content) {
is LayerContent.Image -> "Image"
is LayerContent.Text -> "\"${content.text.take(12)}\""
is LayerContent.Shape -> content.kind.name.lowercase().replaceFirstChar { it.uppercase() }
}
}

/** The element a [Layer] draws. */
@Serializable
sealed interface LayerContent {
@Serializable
data class Image(val sourcePath: String) : LayerContent

@Serializable
data class Text(
val text: String = "Text",
val color: Int = 0xFF000000.toInt(),
val bold: Boolean = true,
val italic: Boolean = false,
val underline: Boolean = false,
val strikethrough: Boolean = false,
val fontName: String? = null, // a system font family; null = default sans
val fontPath: String? = null, // a custom .ttf/.otf (copied into the project); wins over fontName
val letterSpacing: Float = 0f, // TRACKING, as a fraction of the font size
) : LayerContent

@Serializable
data class Shape(val kind: ShapeKind = ShapeKind.CIRCLE, val color: Int = 0xFF00E5FF.toInt()) : LayerContent
}

@Serializable
enum class ShapeKind { CIRCLE, SQUARE, ROUNDED, TRIANGLE, DIAMOND, PENTAGON, HEXAGON, STAR }

/** Drop shadow. Sizes in tile-fraction units so they scale across densities. */
@Serializable
data class Shadow(val offsetX: Float = 0f, val offsetY: Float = 0.03f, val blur: Float = 0.025f, val alpha: Float = 0.45f)

/** Coloured glow behind the element (centered). */
@Serializable
data class Glow(val color: Int = 0xFF00E5FF.toInt(), val blur: Float = 0.035f, val alpha: Float = 0.7f)

/** Solid outline around the element silhouette. [width] in tile-fraction units. */
@Serializable
data class Stroke(val color: Int = 0xFF000000.toInt(), val width: Float = 0.012f)
}
Loading
Loading