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 package moe.lava.awoocord.zinnia
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.View import android.view.View
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.ColorUtils
import com.aliucord.annotations.AliucordPlugin import com.aliucord.annotations.AliucordPlugin
import com.aliucord.entities.Plugin 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.DimenUtils.dp
import com.aliucord.utils.ViewUtils.findViewById import com.aliucord.utils.ViewUtils.findViewById
import com.aliucord.utils.accessField import com.aliucord.utils.accessField
import com.discord.databinding.WidgetChannelMembersListItemUserBinding 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.ChannelMembersListAdapter
import com.discord.widgets.channels.memberlist.adapter.ChannelMembersListViewHolderMember import com.discord.widgets.channels.memberlist.adapter.ChannelMembersListViewHolderMember
import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemMessage import com.discord.widgets.chat.list.adapter.WidgetChatListAdapterItemMessage
import com.discord.widgets.chat.list.entries.ChatListEntry import com.discord.widgets.chat.list.entries.ChatListEntry
import com.discord.widgets.chat.list.entries.MessageEntry import com.discord.widgets.chat.list.entries.MessageEntry
import kotlin.math.abs
private val ChannelMembersListViewHolderMember.binding private val ChannelMembersListViewHolderMember.binding
by accessField<WidgetChannelMembersListItemUserBinding>() by accessField<WidgetChannelMembersListItemUserBinding>()
@ -36,8 +34,6 @@ data class Colours(
class Zinnia : Plugin() { class Zinnia : Plugin() {
companion object { const val NAME = "RoleBlocks" } companion object { const val NAME = "RoleBlocks" }
private val localSettings = ZinniaSettings
init { init {
settingsTab = SettingsTab(ZinniaSettings.Page::class.java, SettingsTab.Type.PAGE) settingsTab = SettingsTab(ZinniaSettings.Page::class.java, SettingsTab.Type.PAGE)
} }
@ -49,93 +45,6 @@ class Zinnia : Plugin() {
override fun stop(context: Context) { patcher.unpatchAll() } 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() { private fun patchMemberList() {
// Patches the method that configures the username in members list // Patches the method that configures the username in members list
patcher.after<ChannelMembersListViewHolderMember>( 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) -> ) { (_, _: Int, entry: MessageEntry) ->
val username = itemView.findViewById<TextView?>("chat_list_adapter_item_text_name") val username = itemView.findViewById<TextView?>("chat_list_adapter_item_text_name")
?: return@after ?: return@after
configureOn(username, entry.author?.color) APCAUtil.configureOn(username, entry.author?.color)
} }
// Configures for reply preview username // Configures for reply preview username
@ -177,7 +86,7 @@ class Zinnia : Plugin() {
val referencedAuthor = entry.replyData?.messageEntry?.author val referencedAuthor = entry.replyData?.messageEntry?.author
val replyUsername = itemView.findViewById<TextView?>("chat_list_adapter_item_text_decorator_reply_name") val replyUsername = itemView.findViewById<TextView?>("chat_list_adapter_item_text_decorator_reply_name")
?: return@after ?: return@after
configureOn(replyUsername, referencedAuthor?.color) APCAUtil.configureOn(replyUsername, referencedAuthor?.color)
} }
} }
} }

View file

@ -1,13 +1,26 @@
package moe.lava.awoocord.zinnia package moe.lava.awoocord.zinnia
import android.graphics.Color
import android.view.View import android.view.View
import android.view.ViewGroup 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.Utils
import com.aliucord.api.SettingsAPI import com.aliucord.api.SettingsAPI
import com.aliucord.fragments.SettingsPage import com.aliucord.fragments.SettingsPage
import com.aliucord.settings.delegate 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.CheckedSetting
import com.discord.views.RadioManager import com.discord.views.RadioManager
import com.lytefast.flexinput.R
import kotlin.math.roundToInt
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@ -25,6 +38,7 @@ enum class BlockMode {
InvertedThemeOnly, InvertedThemeOnly,
WhiteOnly, WhiteOnly,
BlackOnly, BlackOnly,
Unchanged,
} }
class SettingsDelegateEnum<T : Enum<T>>( class SettingsDelegateEnum<T : Enum<T>>(
@ -49,18 +63,47 @@ private inline fun <T : View> T.addTo(parent: ViewGroup, block: T.() -> Unit = {
parent.addView(this) 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 { object ZinniaSettings {
private val api = SettingsAPI(Zinnia.NAME) 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 mode by reactive { api.delegateEnum(Mode.Block) }
var blockInverted by api.delegate(false)
var blockMode by api.delegateEnum(BlockMode.ApcaLightWcagDark) var dotKeepNameColour by reactive { api.delegate(false) }
var blockApcaThreshold by api.delegate(75.0)
var blockWcagThreshold by api.delegate(4.5) 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() { class Page : SettingsPage() {
private lateinit var manager: RadioManager private lateinit var manager: RadioManager
@ -69,6 +112,13 @@ object ZinniaSettings {
private val checks = mutableListOf<CheckedSetting>() 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 { private fun createRadio(newMode: BlockMode, text: String, subtext: String? = null): CheckedSetting {
return Utils.createCheckedSetting(requireContext(), CheckedSetting.ViewType.RADIO, text, subtext).addTo(linearLayout) { return Utils.createCheckedSetting(requireContext(), CheckedSetting.ViewType.RADIO, text, subtext).addTo(linearLayout) {
isChecked = blockMode == newMode 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) { override fun onViewBound(view: View) {
super.onViewBound(view) super.onViewBound(view)
setActionBarTitle(Zinnia.NAME) setActionBarTitle(Zinnia.NAME)
@ -92,11 +196,12 @@ object ZinniaSettings {
val roleDotSettings = mutableListOf<CheckedSetting>() val roleDotSettings = mutableListOf<CheckedSetting>()
addHeader(ctx, "Text colour") addHeader(ctx, "Text colour")
createRadio(BlockMode.ApcaLightWcagDark, "Automatic", "Adjusts text colour based on role colour") createRadio(BlockMode.ApcaLightWcagDark, "Automatic", "Adjusts text colour based on optimal contrast with role colour")
createRadio(BlockMode.ThemeOnly, "By theme", "Adjusts text colour based on theme") 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.InvertedThemeOnly, "By theme (inverted)", "Same as above, but inverted")
createRadio(BlockMode.WhiteOnly, "White", "Force text colour to be white") createRadio(BlockMode.WhiteOnly, "White", "Force text colour to be white")
createRadio(BlockMode.BlackOnly, "Black", "Force text colour to be black") 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") addHeader(ctx, "Mode")
@ -129,24 +234,12 @@ object ZinniaSettings {
*/ */
addHeader(ctx, "Block Settings") 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, ctx,
CheckedSetting.ViewType.SWITCH, CheckedSetting.ViewType.SWITCH,
"Invert block colours", "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) { ).addTo(this) {
isChecked = blockInverted isChecked = blockInverted
setOnCheckedListener { setOnCheckedListener {
@ -154,7 +247,50 @@ object ZinniaSettings {
} }
blockSettings.add(this) 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)
}
} }
} }