From 59d18d76c0a42310a29cc90c63949a41bc649cf4 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Wed, 8 Oct 2025 01:11:59 +1100 Subject: [PATCH] feat(Zinnia): init --- plugins/Zinnia/build.gradle.kts | 12 ++ plugins/Zinnia/src/main/AndroidManifest.xml | 2 + .../kotlin/moe/lava/awoocord/zinnia/APCA.kt | 77 ++++++++ .../kotlin/moe/lava/awoocord/zinnia/Zinnia.kt | 173 ++++++++++++++++++ .../lava/awoocord/zinnia/ZinniaSettings.kt | 134 ++++++++++++++ settings.gradle.kts | 22 +-- 6 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 plugins/Zinnia/build.gradle.kts create mode 100644 plugins/Zinnia/src/main/AndroidManifest.xml create mode 100644 plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt create mode 100644 plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt create mode 100644 plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt diff --git a/plugins/Zinnia/build.gradle.kts b/plugins/Zinnia/build.gradle.kts new file mode 100644 index 0000000..5ee7f56 --- /dev/null +++ b/plugins/Zinnia/build.gradle.kts @@ -0,0 +1,12 @@ +version = "1.0.0" +description = "Coloured usernames to be a bit more pleasing on the eyes" + +aliucord { + // Changelog of your plugin + changelog.set(""" + # 1.0.0 + * Initial release >w< + """.trimIndent()) + + excludeFromUpdaterJson.set(false) +} diff --git a/plugins/Zinnia/src/main/AndroidManifest.xml b/plugins/Zinnia/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ce4638a --- /dev/null +++ b/plugins/Zinnia/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt new file mode 100644 index 0000000..e098626 --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/APCA.kt @@ -0,0 +1,77 @@ +package moe.lava.awoocord.zinnia + +import kotlin.math.abs +import kotlin.math.pow + +// https://github.com/Myndex/apca-w3/blob/c012257167d822f91bc417120bdb82e1b854b4a4/src/apca-w3.js +object APCA { + @Suppress("ConstPropertyName") + private object SA98G { + const val mainTRC = 2.4 + + const val sRco = 0.2126729 + const val sGco = 0.7151522 + const val sBco = 0.0721750 + + const val normBG = 0.56 + const val normTXT = 0.57 + const val revTXT = 0.62 + const val revBG = 0.65 + + const val blkThrs = 0.022 + const val blkClmp = 1.414 + const val scaleBoW = 1.14 + const val scaleWoB = 1.14 + const val loBoWoffset = 0.027 + const val loWoBoffset = 0.027 + const val deltaYmin = 0.0005 + const val loClip = 0.1 + } + + private fun exp(c: Int) = + (c.toDouble() / 255.0).pow(SA98G.mainTRC) + + private fun argbToY(color: Int): Double { + val r = (color shr 16) and 0xff + val g = (color shr 8) and 0xff + val b = color and 0xff + + return SA98G.run { + sRco * exp(r) + sGco * exp(g) + sBco * exp(b) + } + } + + fun contrast(fgC: Int, bgC: Int): Double { + var fg = argbToY(fgC) + var bg = argbToY(bgC) + + if (fg.coerceAtMost(bg) < 0 || fg.coerceAtLeast(bg) > 1.1) + return 0.0 + + if (fg <= SA98G.blkThrs) + fg += (SA98G.blkThrs - fg).pow(SA98G.blkClmp) + if (bg <= SA98G.blkThrs) + bg += (SA98G.blkThrs - bg).pow(SA98G.blkClmp) + + if (abs(bg - fg) < SA98G.deltaYmin) + return 0.0 + + val outputContrast = if (bg > fg) { + val sapc = (bg.pow(SA98G.normBG) - fg.pow(SA98G.normTXT)) * SA98G.scaleBoW + + if (sapc < SA98G.loClip) + 0.0 + else + sapc - SA98G.loBoWoffset + } else { + val sapc = (bg.pow(SA98G.revBG) - fg.pow(SA98G.revTXT)) * SA98G.scaleWoB + + if (sapc > -SA98G.loClip) + 0.0 + else + sapc + SA98G.loWoBoffset + } + + return outputContrast * 100 + } +} diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt new file mode 100644 index 0000000..6b0f115 --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/Zinnia.kt @@ -0,0 +1,173 @@ +package moe.lava.awoocord.zinnia + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.view.View +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.graphics.ColorUtils +import com.aliucord.annotations.AliucordPlugin +import com.aliucord.entities.Plugin +import com.aliucord.patcher.* +import com.aliucord.utils.DimenUtils.dp +import com.aliucord.utils.ViewUtils.findViewById +import com.aliucord.utils.accessField +import com.discord.databinding.WidgetChannelMembersListItemUserBinding +import com.discord.stores.StoreStream +import com.discord.widgets.channels.memberlist.adapter.ChannelMembersListAdapter +import com.discord.widgets.channels.memberlist.adapter.ChannelMembersListViewHolderMember +import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemMessage +import com.discord.widgets.chat.list.entries.ChatListEntry +import com.discord.widgets.chat.list.entries.MessageEntry +import kotlin.math.abs + +private val ChannelMembersListViewHolderMember.binding + by accessField() + +data class Colours( + val fgP: Int, + val bgP: Int, + val fgO: Int, + val bgO: Int, +) + +@AliucordPlugin +class Zinnia : Plugin() { + companion object { const val NAME = "RoleBlocks" } + + private val localSettings = ZinniaSettings + + init { + settingsTab = SettingsTab(ZinniaSettings.Page::class.java, SettingsTab.Type.PAGE) + } + + override fun start(context: Context) { + patchMemberList() + patchMessageAuthor() + } + + override fun stop(context: Context) { patcher.unpatchAll() } + + private fun configureOn(view: TextView, colour: Int?) { + when (localSettings.mode) { + Mode.Block -> configureBlock(view, colour ?: Color.BLACK) + Mode.RoleDot -> configureRoleDot(view, colour ?: Color.BLACK) + } + } + + private fun configureRoleDot(view: TextView, colour: Int) { } + + private fun configureBlock(view: TextView, colourP: Int) { + val isLight = StoreStream.getUserSettingsSystem().theme == "light" + var colour = colourP + val bcol = GradientDrawable() + bcol.cornerRadius = 4.dp.toFloat() + view.background = bcol + + if (colour == Color.BLACK) { + if (localSettings.blockAlsoDefault) { + colour = if (isLight && !localSettings.blockInverted) Color.WHITE else Color.BLACK + } else { + view.background = null + view.setPadding(0, 0, 0, 0) + return + } + } + view.setPadding(4.dp, 0, 4.dp, 0) + + val (preferred, other) = if (isLight) { + Color.WHITE to Color.BLACK + } else { + Color.BLACK to Color.WHITE + } + + val colours = if (!localSettings.blockInverted) { + Colours( + fgP = preferred, + fgO = other, + bgP = colour, + bgO = colour, + ) + } else { + Colours( + fgP = colour, + fgO = colour, + bgP = preferred, + bgO = other, + ) + } + + val usePreferred = when (localSettings.blockMode) { + BlockMode.ApcaOnly -> isApca(colours) + BlockMode.WcagOnly -> isWcag(colours) + BlockMode.ApcaLightWcagDark -> if (isLight) isApca(colours) else isWcag(colours) + BlockMode.WcagLightApcaDark -> if (isLight) isWcag(colours) else isApca(colours) + } + + if (usePreferred) { + view.setTextColor(colours.fgP) + bcol.setColor(colours.bgP) + } else { + view.setTextColor(colours.fgO) + bcol.setColor(colours.bgO) + } + } + + private fun isApca(c: Colours): Boolean { + val cPref = abs(APCA.contrast(c.fgP, c.bgP)) + val cOth = abs(APCA.contrast(c.fgO, c.bgO)) + return cPref > localSettings.blockApcaThreshold || cPref > cOth + } + + private fun isWcag(c: Colours): Boolean { + val cPref = ColorUtils.calculateContrast(c.fgP, c.bgP) + val cOth = ColorUtils.calculateContrast(c.fgO, c.bgO) + return cPref > localSettings.blockWcagThreshold || cPref > cOth + } + + private fun patchMemberList() { + // Patches the method that configures the username in members list + patcher.after( + "bind", + ChannelMembersListAdapter.Item.Member::class.java, + Function0::class.java, + ) { (_, member: ChannelMembersListAdapter.Item.Member) -> + val presenceTextView = binding.d + val usernameView = binding.f + val usernameTextView = usernameView.j.c + + if (presenceTextView.visibility == View.VISIBLE) { + usernameView.layoutParams = (usernameView.layoutParams as ConstraintLayout.LayoutParams).apply { + bottomMargin = 2.dp + } + } + + configureOn(usernameTextView, member.color) + } + } + + private fun patchMessageAuthor() { + // Configures for message author username + patcher.after( + "onConfigure", + Int::class.javaPrimitiveType!!, + ChatListEntry::class.java, + ) { (_, _: Int, entry: MessageEntry) -> + val username = itemView.findViewById("chat_list_adapter_item_text_name") + ?: return@after + configureOn(username, entry.author?.color) + } + + // Configures for reply preview username + patcher.after( + "configureReplyPreview", + MessageEntry::class.java, + ) { (_, entry: MessageEntry) -> + val referencedAuthor = entry.replyData?.messageEntry?.author + val replyUsername = itemView.findViewById("chat_list_adapter_item_text_decorator_reply_name") + ?: return@after + configureOn(replyUsername, referencedAuthor?.color) + } + } +} diff --git a/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt new file mode 100644 index 0000000..3d44b19 --- /dev/null +++ b/plugins/Zinnia/src/main/kotlin/moe/lava/awoocord/zinnia/ZinniaSettings.kt @@ -0,0 +1,134 @@ +package moe.lava.awoocord.zinnia + +import android.view.View +import android.view.ViewGroup +import com.aliucord.Utils +import com.aliucord.api.SettingsAPI +import com.aliucord.fragments.SettingsPage +import com.aliucord.settings.delegate +import com.discord.views.CheckedSetting +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + +enum class Mode { + RoleDot, + Block, +} + +enum class BlockMode { + ApcaLightWcagDark, + WcagLightApcaDark, + ApcaOnly, + WcagOnly, +} + +class SettingsDelegateEnum>( + private val defaultValue: T, + private val settings: SettingsAPI, + private val deserialiser: (String) -> T, +) : ReadWriteProperty { + override fun getValue(thisRef: Any, property: KProperty<*>): T = + deserialiser(settings.getString(property.name, defaultValue.name)) + + override fun setValue(thisRef: Any, property: KProperty<*>, value: T) = + settings.setString(property.name, value.name) +} + +inline fun > SettingsAPI.delegateEnum( + defaultValue: T +) = SettingsDelegateEnum(defaultValue, this) { enumValueOf(it) } + +private inline fun T.addTo(parent: ViewGroup, block: T.() -> Unit = {}) = + apply { + block() + parent.addView(this) + } + +object ZinniaSettings { + private val api = SettingsAPI(Zinnia.NAME) + + var mode by api.delegateEnum(Mode.Block) + + var dotKeepNameColour by api.delegate(false) + + var blockAlsoDefault by api.delegate(true) + var blockInverted by api.delegate(false) + var blockMode by api.delegateEnum(BlockMode.ApcaLightWcagDark) + var blockApcaThreshold by api.delegate(75.0) + var blockWcagThreshold by api.delegate(4.5) + + @Suppress("MISSING_DEPENDENCY_CLASS", "MISSING_DEPENDENCY_SUPERCLASS") + class Page : SettingsPage() { + private lateinit var mRoleDot: CheckedSetting + private lateinit var mBlock: CheckedSetting + + override fun onViewBound(view: View) { + super.onViewBound(view) + setActionBarTitle(Zinnia.NAME) + setPadding(0) + + val ctx = requireContext() + linearLayout.run { + val blockSettings = mutableListOf() + val roleDotSettings = mutableListOf() + + /* + addHeader(ctx, "Mode") + + mBlock = Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.RADIO, + "Block mode", + "Wraps the username in a coloured block", + ).addTo(this) { + isChecked = mode == Mode.Block + setOnCheckedListener { + mode = Mode.Block + mRoleDot.isChecked = false + } + } + + mRoleDot = Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.RADIO, + "Role dot mode", + "Adds a coloured role dot next to the username, similar to how Discord does it in their new accessibility settings", + ).addTo(this) { + isChecked = mode == Mode.RoleDot + setOnCheckedListener { + mode = Mode.RoleDot + mBlock.isChecked = false + } + } + */ + + addHeader(ctx, "Block Settings") + Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + "Also block up default colours", + "Blocks up usernames that have no role colour", + ).addTo(this) { + isChecked = blockAlsoDefault + setOnCheckedListener { + blockAlsoDefault = !blockAlsoDefault + } + blockSettings.add(this) + } + + Utils.createCheckedSetting( + ctx, + CheckedSetting.ViewType.SWITCH, + "Invert block colours", + "By default, the role colour is applied as the block background. Turning this setting on instead makes the block black or white, and the text stays coloured.", + ).addTo(this) { + isChecked = blockInverted + setOnCheckedListener { + blockInverted = !blockInverted + } + blockSettings.add(this) + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 739eadd..cbcb171 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,16 +1,16 @@ rootProject.name = "Awoocord" -val canaryPlugins = arrayOf("ComponentsV2", "SlashCommandsFix") - -include( - "Scout", - *canaryPlugins, +val plugins = mapOf( + "ComponentsV2Beta" to "canary/ComponentsV2", + "SlashCommandsFixBeta" to "canary/SlashCommandsFix", + "Scout" to "plugins/Scout", + "RoleBlocks" to "plugins/Zinnia", ) -rootProject.children.forEach { - val isCanary = it.name in canaryPlugins - val dir = if (isCanary) "canary" else "plugins" - val name = it.name - if (isCanary) it.name += "Beta" - it.projectDir = file("${dir}/${name}") +include(*plugins.keys.toTypedArray()) + +rootProject.children.forEach { project -> + plugins[project.name]?.let { + project.projectDir = file(it) + } }