feat(Zinnia): refactor and add like, a bunch of stuff

- refactor: move colour utilities to APCAUtil, for sharing with settings
  preview
- feat: speaking of which, a nice preview in settings!! featuring hsv
  bars for all your previewing needs
- feat: changed apca threshold to 45, I found this to be nicer than 75
- feat: added transparency option, alongside "unchanged" colour option
  which pairs nicely together for a translucent glass effect
This commit is contained in:
Cilly Leang 2026-02-17 16:33:00 +11:00
parent bc4aa09fff
commit e0b86e0fb4
Signed by: cilly
GPG key ID: 6500251E087653C9
3 changed files with 271 additions and 121 deletions

View file

@ -0,0 +1,105 @@
package moe.lava.awoocord.zinnia
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.widget.TextView
import androidx.core.graphics.ColorUtils
import com.aliucord.utils.DimenUtils.dp
import com.discord.stores.StoreStream
import kotlin.math.abs
internal object APCAUtil {
private val settings = ZinniaSettings
internal fun configureOn(view: TextView, colour: Int?) {
when (settings.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 (settings.blockAlsoDefault) {
colour = if (isLight && !settings.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)
var (preferred, other) = if (isLight) {
Color.WHITE to Color.BLACK
} else {
Color.BLACK to Color.WHITE
}
when (settings.blockMode) {
BlockMode.InvertedThemeOnly -> preferred = other
BlockMode.WhiteOnly -> preferred = Color.WHITE
BlockMode.BlackOnly -> preferred = Color.BLACK
BlockMode.Unchanged -> preferred = colourP
else -> {}
}
val colours = if (!settings.blockInverted) {
Colours(
fgP = preferred,
fgO = other,
bgP = colour,
bgO = colour,
)
} else {
Colours(
fgP = colour,
fgO = colour,
bgP = preferred,
bgO = other,
)
}
val usePreferred = when (settings.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)
BlockMode.ThemeOnly,
BlockMode.InvertedThemeOnly,
BlockMode.WhiteOnly,
BlockMode.BlackOnly,
BlockMode.Unchanged -> true
}
if (usePreferred) {
view.setTextColor(colours.fgP)
bcol.setColor(ColorUtils.setAlphaComponent(colours.bgP, settings.alpha))
bcol.alpha = settings.alpha
} else {
view.setTextColor(colours.fgO)
bcol.setColor(ColorUtils.setAlphaComponent(colours.bgO, settings.alpha))
bcol.alpha = settings.alpha
}
}
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 > settings.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 > settings.blockWcagThreshold || cPref > cOth
}
}

View file

@ -1,26 +1,24 @@
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.patcher.after
import com.aliucord.patcher.component1
import com.aliucord.patcher.component2
import com.aliucord.patcher.component3
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<WidgetChannelMembersListItemUserBinding>()
@ -36,8 +34,6 @@ data class Colours(
class Zinnia : Plugin() {
companion object { const val NAME = "RoleBlocks" }
private val localSettings = ZinniaSettings
init {
settingsTab = SettingsTab(ZinniaSettings.Page::class.java, SettingsTab.Type.PAGE)
}
@ -49,93 +45,6 @@ class Zinnia : Plugin() {
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)
var (preferred, other) = if (isLight) {
Color.WHITE to Color.BLACK
} else {
Color.BLACK to Color.WHITE
}
when (localSettings.blockMode) {
BlockMode.InvertedThemeOnly -> preferred = other
BlockMode.WhiteOnly -> preferred = Color.WHITE
BlockMode.BlackOnly -> preferred = Color.BLACK
else -> {}
}
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)
BlockMode.ThemeOnly,
BlockMode.InvertedThemeOnly,
BlockMode.WhiteOnly,
BlockMode.BlackOnly -> true
}
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<ChannelMembersListViewHolderMember>(
@ -153,7 +62,7 @@ class Zinnia : Plugin() {
}
}
configureOn(usernameTextView, member.color)
APCAUtil.configureOn(usernameTextView, member.color)
}
}
@ -166,7 +75,7 @@ class Zinnia : Plugin() {
) { (_, _: Int, entry: MessageEntry) ->
val username = itemView.findViewById<TextView?>("chat_list_adapter_item_text_name")
?: return@after
configureOn(username, entry.author?.color)
APCAUtil.configureOn(username, entry.author?.color)
}
// Configures for reply preview username
@ -177,7 +86,7 @@ class Zinnia : Plugin() {
val referencedAuthor = entry.replyData?.messageEntry?.author
val replyUsername = itemView.findViewById<TextView?>("chat_list_adapter_item_text_decorator_reply_name")
?: return@after
configureOn(replyUsername, referencedAuthor?.color)
APCAUtil.configureOn(replyUsername, referencedAuthor?.color)
}
}
}

View file

@ -1,13 +1,26 @@
package moe.lava.awoocord.zinnia
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import android.widget.SeekBar
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import com.aliucord.Constants
import com.aliucord.Utils
import com.aliucord.api.SettingsAPI
import com.aliucord.fragments.SettingsPage
import com.aliucord.settings.delegate
import com.aliucord.utils.DimenUtils.dp
import com.aliucord.wrappers.users.globalName
import com.discord.stores.StoreStream
import com.discord.utilities.color.ColorCompat
import com.discord.views.CheckedSetting
import com.discord.views.RadioManager
import com.lytefast.flexinput.R
import kotlin.math.roundToInt
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
@ -25,6 +38,7 @@ enum class BlockMode {
InvertedThemeOnly,
WhiteOnly,
BlackOnly,
Unchanged,
}
class SettingsDelegateEnum<T : Enum<T>>(
@ -49,18 +63,47 @@ private inline fun <T : View> T.addTo(parent: ViewGroup, block: T.() -> Unit = {
parent.addView(this)
}
private typealias Delegate<Type> = ReadWriteProperty<Any, Type>
fun <T> basicDelegate(initial: T) = object : Delegate<T> {
private var current = initial
override fun getValue(self: Any?, prop: KProperty<*>): T = current
override fun setValue(self: Any?, prop: KProperty<*>, value: T) { current = value }
}
private class StateDelegate<T>(
private val inner: Delegate<T>,
private val update: (T) -> Unit,
) : Delegate<T> {
override fun getValue(self: Any?, prop: KProperty<*>): T = inner.getValue(self, prop)
override fun setValue(self: Any?, prop: KProperty<*>, value: T) {
inner.setValue(self, prop, value)
update(value)
}
}
object ZinniaSettings {
private val api = SettingsAPI(Zinnia.NAME)
var mode by api.delegateEnum(Mode.Block)
private var onStateUpdate = {}
var dotKeepNameColour by api.delegate(false)
private inline fun <T> reactive(backing: () -> Delegate<T>): StateDelegate<T> {
return StateDelegate(backing()) { onStateUpdate() }
}
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)
var mode by reactive { api.delegateEnum(Mode.Block) }
var dotKeepNameColour by reactive { api.delegate(false) }
var blockAlsoDefault by reactive { api.delegate(true) }
var blockInverted by reactive { api.delegate(false) }
var blockMode by reactive { api.delegateEnum(BlockMode.ApcaLightWcagDark) }
var blockApcaThreshold by reactive { api.delegate(45.0f) }
var blockWcagThreshold by reactive { api.delegate(4.5f) }
private val _alpha = reactive { api.delegate("alpha", 255) }
var alpha by _alpha
class Page : SettingsPage() {
private lateinit var manager: RadioManager
@ -69,6 +112,13 @@ object ZinniaSettings {
private val checks = mutableListOf<CheckedSetting>()
private val _previewH = reactive { basicDelegate(0) }
private var previewH by _previewH
private val _previewS = reactive { basicDelegate(100) }
private var previewS by _previewS
private val _previewV = reactive { basicDelegate(100) }
private var previewV by _previewV
private fun createRadio(newMode: BlockMode, text: String, subtext: String? = null): CheckedSetting {
return Utils.createCheckedSetting(requireContext(), CheckedSetting.ViewType.RADIO, text, subtext).addTo(linearLayout) {
isChecked = blockMode == newMode
@ -81,6 +131,60 @@ object ZinniaSettings {
}
}
private fun createSlider(
min: Int,
max: Int,
initial: Int = min,
onChange: (value: Int, commit: Boolean) -> String
): LinearLayout {
var pendingValue = initial
return LinearLayout(requireContext(), null, 0, R.i.UiKit_Settings_Item).addTo(linearLayout) {
orientation = LinearLayout.VERTICAL
val display = TextView(context, null, 0, R.i.UiKit_TextView).addTo(this) {
textSize = 16.0f
typeface = ResourcesCompat.getFont(context, Constants.Fonts.whitney_medium)
text = onChange(initial, false)
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
bottomMargin = 4.dp
}
}
SeekBar(context, null, 0, R.i.UiKit_SeekBar).addTo(this) {
this.max = max - min
progress = initial
setPadding(12.dp, 0, 12.dp, 0)
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(
seekBar: SeekBar,
progress: Int,
fromUser: Boolean,
) {
pendingValue = min + progress
display.text = onChange(pendingValue, false)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {
onChange(pendingValue, true)
}
})
}
}
}
private fun createSlider(binding: Delegate<Int>, min: Int, max: Int, immediate: Boolean = false, label: (Int) -> String): LinearLayout {
var value by binding
return createSlider(min, max, value) { newValue, commit ->
@Suppress("AssignedValueIsNeverRead") // kt so dumb
if (immediate || commit) value = newValue
label(newValue)
}
}
override fun onDestroyView() {
onStateUpdate = {}
super.onDestroyView()
}
override fun onViewBound(view: View) {
super.onViewBound(view)
setActionBarTitle(Zinnia.NAME)
@ -92,11 +196,12 @@ object ZinniaSettings {
val roleDotSettings = mutableListOf<CheckedSetting>()
addHeader(ctx, "Text colour")
createRadio(BlockMode.ApcaLightWcagDark, "Automatic", "Adjusts text colour based on role colour")
createRadio(BlockMode.ThemeOnly, "By theme", "Adjusts text colour based on theme")
createRadio(BlockMode.ApcaLightWcagDark, "Automatic", "Adjusts text colour based on optimal contrast with role colour")
createRadio(BlockMode.ThemeOnly, "By theme", "Adjusts text colour based on system theme (dark/light)")
createRadio(BlockMode.InvertedThemeOnly, "By theme (inverted)", "Same as above, but inverted")
createRadio(BlockMode.WhiteOnly, "White", "Force text colour to be white")
createRadio(BlockMode.BlackOnly, "Black", "Force text colour to be black")
createRadio(BlockMode.Unchanged, "Unchanged", "Keep text colour; ideal for using with a translucent block")
/*
addHeader(ctx, "Mode")
@ -129,24 +234,12 @@ object ZinniaSettings {
*/
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(
val invertSwitch = 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.",
"By default, the role colour is applied as the block background. Turning this setting on inverts this.\nHas no effect with \"Unchanged\" colour option",
).addTo(this) {
isChecked = blockInverted
setOnCheckedListener {
@ -154,7 +247,50 @@ object ZinniaSettings {
}
blockSettings.add(this)
}
createSlider(_alpha, 0, 255, true) { "Alpha: ${(it / 2.55f).roundToInt()}%" }
// createSlider(0, 255, blockApcaThreshold.roundToInt()) { value, commit ->
// blockApcaThreshold = value.toFloat()
// "Apca Threshold: $value"
// }
addHeader(ctx, "Preview")
val preview = TextView(ctx, null, 0, R.i.UiKit_TextView_Large_SingleLine).addTo(this) {
val me = StoreStream.getUsers().me
text = me.globalName ?: me.username
layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
marginStart = 16.dp
marginEnd = 16.dp
}
}
val hsv = floatArrayOf(0f, 0f, 0f)
Color.colorToHSV(ColorCompat.getThemedColor(this, R.b.color_brand), hsv)
previewH = hsv[0].roundToInt()
previewS = (hsv[1] * 100).roundToInt()
previewV = (hsv[2] * 100).roundToInt()
createSlider(_previewH, 0, 360, true) { "Hue: $it" }
createSlider(_previewS, 0, 100, true) { "Saturation: $it%" }
createSlider(_previewV, 0, 100, true) { "Value: $it%" }
onStateUpdate = {
updatePreview(preview)
if (blockMode != BlockMode.Unchanged) {
invertSwitch.l.b().isClickable = true
invertSwitch.alpha = 1f
} else {
invertSwitch.l.b().isClickable = false
invertSwitch.alpha = 0.3f
}
}
onStateUpdate()
}
}
fun updatePreview(preview: TextView) {
val colour = Color.HSVToColor(floatArrayOf(previewH.toFloat(), previewS / 100f, previewV / 100f))
APCAUtil.configureOn(preview, colour)
}
}
}