Refine clock color expressions
This commit is contained in:
+512
-238
@@ -1,8 +1,6 @@
|
||||
package se.ajpanton.statusbartweak.runtime.features.clock;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
@@ -10,6 +8,11 @@ import java.util.Locale;
|
||||
import se.ajpanton.statusbartweak.shell.settings.BatteryBarStyle;
|
||||
|
||||
final class ClockColorResolver {
|
||||
private static final int CHANNEL_ALPHA = 0;
|
||||
private static final int CHANNEL_RED = 1;
|
||||
private static final int CHANNEL_GREEN = 2;
|
||||
private static final int CHANNEL_BLUE = 3;
|
||||
private static final int CHANNEL_COUNT = 4;
|
||||
|
||||
String resolveColorHex(
|
||||
Context context,
|
||||
@@ -18,7 +21,7 @@ final class ClockColorResolver {
|
||||
int fallbackColor
|
||||
) {
|
||||
int resolved = resolveColor(context, expression, referenceColor, fallbackColor);
|
||||
if (Color.alpha(resolved) >= 255) {
|
||||
if (alpha(resolved) >= 255) {
|
||||
return String.format(Locale.ROOT, "#%06X", resolved & 0xFFFFFF);
|
||||
}
|
||||
return String.format(Locale.ROOT, "#%08X", resolved);
|
||||
@@ -30,229 +33,361 @@ final class ClockColorResolver {
|
||||
int referenceColor,
|
||||
int fallbackColor
|
||||
) {
|
||||
if (TextUtils.isEmpty(expression)) {
|
||||
if (isEmpty(expression)) {
|
||||
return fallbackColor;
|
||||
}
|
||||
ArrayList<String> segments = splitSegments(expression);
|
||||
if (segments.isEmpty() || segments.size() > 4) {
|
||||
throw new IllegalArgumentException("Invalid colour expression");
|
||||
VariantExpressions variants = splitVariants(expression);
|
||||
Value bright = evaluateExpression(context, variants.brightExpression, null, fallbackColor);
|
||||
Value dark = variants.darkExpression.isEmpty()
|
||||
? bright
|
||||
: evaluateExpression(context, variants.darkExpression, bright, fallbackColor);
|
||||
return toColor(isDark(referenceColor) ? dark : bright);
|
||||
}
|
||||
|
||||
private Value evaluateExpression(
|
||||
Context context,
|
||||
String expression,
|
||||
Value inherited,
|
||||
int fallbackColor
|
||||
) {
|
||||
ArrayList<String> tokens = tokenize(expression);
|
||||
ArrayList<Value> stack = new ArrayList<>();
|
||||
if (inherited != null) {
|
||||
stack.add(inherited.copy());
|
||||
}
|
||||
int brightColor;
|
||||
int darkColor;
|
||||
switch (segments.size()) {
|
||||
case 1:
|
||||
brightColor = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
|
||||
darkColor = brightColor;
|
||||
break;
|
||||
case 2:
|
||||
brightColor = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
|
||||
darkColor = resolveVariant(
|
||||
context,
|
||||
brightColor,
|
||||
segments.get(1),
|
||||
referenceColor,
|
||||
fallbackColor);
|
||||
break;
|
||||
case 3:
|
||||
int sharedSource = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
|
||||
brightColor = resolveVariant(
|
||||
context,
|
||||
sharedSource,
|
||||
segments.get(1),
|
||||
referenceColor,
|
||||
fallbackColor);
|
||||
darkColor = resolveVariant(
|
||||
context,
|
||||
sharedSource,
|
||||
segments.get(2),
|
||||
referenceColor,
|
||||
fallbackColor);
|
||||
break;
|
||||
case 4:
|
||||
int darkSource = resolveSource(context, segments.get(0), referenceColor, fallbackColor);
|
||||
int brightSource = resolveSource(context, segments.get(1), referenceColor, fallbackColor);
|
||||
brightColor = resolveVariant(
|
||||
context,
|
||||
brightSource,
|
||||
segments.get(2),
|
||||
referenceColor,
|
||||
fallbackColor);
|
||||
darkColor = resolveVariant(
|
||||
context,
|
||||
darkSource,
|
||||
segments.get(3),
|
||||
referenceColor,
|
||||
fallbackColor);
|
||||
break;
|
||||
for (String token : tokens) {
|
||||
applyToken(context, stack, token, fallbackColor);
|
||||
}
|
||||
if (stack.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty colour expression");
|
||||
}
|
||||
return top(stack).copy();
|
||||
}
|
||||
|
||||
private void applyToken(Context context, ArrayList<Value> stack, String token, int fallbackColor) {
|
||||
if (isEmpty(token)) {
|
||||
return;
|
||||
}
|
||||
Operation operation = parseOperation(token);
|
||||
if (operation != null) {
|
||||
applyOperation(stack, operation);
|
||||
return;
|
||||
}
|
||||
stack.add(parseOperand(context, token, fallbackColor));
|
||||
}
|
||||
|
||||
private Operation parseOperation(String token) {
|
||||
String value = token != null ? token.trim() : "";
|
||||
if (value.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
char first = value.charAt(0);
|
||||
if ((first == '+' || first == '-' || first == '*' || first == '/' || first == '^')
|
||||
&& isChannelSuffix(value.substring(1))) {
|
||||
return new Operation(String.valueOf(first), channelsForSuffix(value.substring(1)));
|
||||
}
|
||||
String lower = value.toLowerCase(Locale.ROOT);
|
||||
for (String name : new String[] {"inv", "min", "max", "sqrt", "clip", "dup"}) {
|
||||
if (!lower.startsWith(name)) {
|
||||
continue;
|
||||
}
|
||||
String suffix = value.substring(name.length());
|
||||
if ("dup".equals(name) && !suffix.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (!isChannelSuffix(suffix)) {
|
||||
continue;
|
||||
}
|
||||
return new Operation(name, channelsForSuffix(suffix));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void applyOperation(ArrayList<Value> stack, Operation operation) {
|
||||
switch (operation.name) {
|
||||
case "+":
|
||||
case "-":
|
||||
case "*":
|
||||
case "/":
|
||||
case "^":
|
||||
applyBinaryMath(stack, operation);
|
||||
return;
|
||||
case "inv":
|
||||
applyInvert(stack, operation.channels);
|
||||
return;
|
||||
case "min":
|
||||
case "max":
|
||||
applyMinMax(stack, operation);
|
||||
return;
|
||||
case "sqrt":
|
||||
applySqrt(stack, operation.channels);
|
||||
return;
|
||||
case "clip":
|
||||
replaceTop(stack, top(stack).clip(operation.channels));
|
||||
return;
|
||||
case "dup":
|
||||
stack.add(top(stack).copy());
|
||||
return;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported colour expression");
|
||||
throw new IllegalArgumentException("Unknown colour operation");
|
||||
}
|
||||
return isDark(referenceColor) ? darkColor : brightColor;
|
||||
}
|
||||
|
||||
private int resolveVariant(
|
||||
Context context,
|
||||
int sourceColor,
|
||||
String spec,
|
||||
int referenceColor,
|
||||
int fallbackColor
|
||||
) {
|
||||
String value = spec != null ? spec.trim() : "";
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty variant");
|
||||
private void applyBinaryMath(ArrayList<Value> stack, Operation operation) {
|
||||
Value right = pop(stack);
|
||||
Value left = pop(stack);
|
||||
if (!left.vector && !right.vector) {
|
||||
stack.add(Value.number(applyBinary(left.number, right.number, operation.name)));
|
||||
return;
|
||||
}
|
||||
if (value.charAt(0) == '#') {
|
||||
return resolveSource(context, value, referenceColor, fallbackColor);
|
||||
double[] leftVector = left.asVector();
|
||||
double[] rightVector = right.asVector();
|
||||
double[] result = passThroughVector(left, right);
|
||||
boolean[] channels = operation.channels;
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++) {
|
||||
if (!channels[i]) {
|
||||
continue;
|
||||
}
|
||||
result[i] = applyBinary(leftVector[i], rightVector[i], operation.name);
|
||||
}
|
||||
return applyMathExpression(sourceColor, value);
|
||||
stack.add(Value.vector(result));
|
||||
}
|
||||
|
||||
private int resolveSource(
|
||||
Context context,
|
||||
String source,
|
||||
int referenceColor,
|
||||
int fallbackColor
|
||||
) {
|
||||
String value = source != null ? source.trim() : "";
|
||||
private double applyBinary(double left, double right, String operator) {
|
||||
switch (operator) {
|
||||
case "+":
|
||||
return left + right;
|
||||
case "-":
|
||||
return left - right;
|
||||
case "*":
|
||||
return left * right;
|
||||
case "/":
|
||||
return left / right;
|
||||
case "^":
|
||||
return Math.pow(left, right);
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown binary operation");
|
||||
}
|
||||
}
|
||||
|
||||
private double[] passThroughVector(Value left, Value right) {
|
||||
if (right.vector) {
|
||||
return right.vectorValues.clone();
|
||||
}
|
||||
if (left.vector) {
|
||||
return left.vectorValues.clone();
|
||||
}
|
||||
throw new IllegalArgumentException("Missing pass-through vector");
|
||||
}
|
||||
|
||||
private void applyInvert(ArrayList<Value> stack, boolean[] channels) {
|
||||
Value value = pop(stack);
|
||||
if (!value.vector) {
|
||||
throw new IllegalArgumentException("inv requires a vector");
|
||||
}
|
||||
double[] result = value.vectorValues.clone();
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++) {
|
||||
if (channels[i]) {
|
||||
result[i] = 255d - result[i];
|
||||
}
|
||||
}
|
||||
stack.add(Value.vector(result));
|
||||
}
|
||||
|
||||
private void applyMinMax(ArrayList<Value> stack, Operation operation) {
|
||||
Value right = pop(stack);
|
||||
if (right.vector) {
|
||||
stack.add(Value.number(extreme(right.vectorValues, operation.channels, "max".equals(operation.name))));
|
||||
return;
|
||||
}
|
||||
Value left = pop(stack);
|
||||
if (left.vector) {
|
||||
throw new IllegalArgumentException(operation.name + " cannot compare vector with number");
|
||||
}
|
||||
stack.add(Value.number("max".equals(operation.name)
|
||||
? Math.max(left.number, right.number)
|
||||
: Math.min(left.number, right.number)));
|
||||
}
|
||||
|
||||
private double extreme(double[] values, boolean[] channels, boolean maximum) {
|
||||
Double result = null;
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++) {
|
||||
if (!channels[i]) {
|
||||
continue;
|
||||
}
|
||||
if (result == null) {
|
||||
result = values[i];
|
||||
} else if (maximum) {
|
||||
result = Math.max(result, values[i]);
|
||||
} else {
|
||||
result = Math.min(result, values[i]);
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
throw new IllegalArgumentException("No channels selected");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void applySqrt(ArrayList<Value> stack, boolean[] channels) {
|
||||
Value value = pop(stack);
|
||||
if (!value.vector) {
|
||||
stack.add(Value.number(Math.sqrt(value.number)));
|
||||
return;
|
||||
}
|
||||
double[] result = value.vectorValues.clone();
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++) {
|
||||
if (channels[i]) {
|
||||
result[i] = Math.sqrt(result[i]);
|
||||
}
|
||||
}
|
||||
stack.add(Value.vector(result));
|
||||
}
|
||||
|
||||
private Value parseOperand(Context context, String token, int fallbackColor) {
|
||||
String value = token != null ? token.trim() : "";
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty colour source");
|
||||
throw new IllegalArgumentException("Empty colour token");
|
||||
}
|
||||
if ("#batterybar".equalsIgnoreCase(value)) {
|
||||
return BatteryBarStyle.resolveCurrentColor(context, referenceColor, fallbackColor);
|
||||
return Value.vector(vectorForColor(BatteryBarStyle.resolveCurrentColor(context, fallbackColor, fallbackColor)));
|
||||
}
|
||||
return parseHexColor(value);
|
||||
}
|
||||
|
||||
private int applyMathExpression(int sourceColor, String expression) {
|
||||
String value = expression != null ? expression.trim() : "";
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty colour math expression");
|
||||
if (value.charAt(0) == '#') {
|
||||
return Value.vector(vectorForColor(parseHexColor(value)));
|
||||
}
|
||||
double[] rgb = new double[] {
|
||||
Color.red(sourceColor),
|
||||
Color.green(sourceColor),
|
||||
Color.blue(sourceColor)
|
||||
};
|
||||
double alpha = Color.alpha(sourceColor);
|
||||
int index = 0;
|
||||
if (value.regionMatches(true, 0, "inv", 0, 3)) {
|
||||
rgb[0] = 255d - rgb[0];
|
||||
rgb[1] = 255d - rgb[1];
|
||||
rgb[2] = 255d - rgb[2];
|
||||
index = 3;
|
||||
if (value.charAt(0) == '[') {
|
||||
return Value.vector(parseVector(value));
|
||||
}
|
||||
while (index < value.length()) {
|
||||
while (index < value.length() && Character.isWhitespace(value.charAt(index))) {
|
||||
index++;
|
||||
}
|
||||
if (index >= value.length()) {
|
||||
break;
|
||||
}
|
||||
char operator = value.charAt(index);
|
||||
if (operator != '+' && operator != '-' && operator != '*' && operator != '/') {
|
||||
throw new IllegalArgumentException("Invalid colour operation");
|
||||
}
|
||||
index++;
|
||||
while (index < value.length() && Character.isWhitespace(value.charAt(index))) {
|
||||
index++;
|
||||
}
|
||||
if (index >= value.length()) {
|
||||
throw new IllegalArgumentException("Missing colour operand");
|
||||
}
|
||||
int operandStart = index;
|
||||
if (value.charAt(index) == '#') {
|
||||
index++;
|
||||
while (index < value.length() && isHexDigit(value.charAt(index))) {
|
||||
index++;
|
||||
}
|
||||
} else {
|
||||
while (index < value.length()) {
|
||||
char c = value.charAt(index);
|
||||
if (!Character.isDigit(c) && c != '.') {
|
||||
break;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
String operandText = value.substring(operandStart, index).trim();
|
||||
if (operandText.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing colour operand");
|
||||
}
|
||||
Operand operand = parseOperand(operandText);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
double rhs = operand.rgb[i];
|
||||
if (operand.isHex && (operator == '*' || operator == '/')) {
|
||||
rhs = rhs / 255d;
|
||||
}
|
||||
rgb[i] = applyOperator(rgb[i], rhs, operator);
|
||||
}
|
||||
if (operand.hasAlpha) {
|
||||
double rhs = operand.alpha;
|
||||
if (operand.isHex && (operator == '*' || operator == '/')) {
|
||||
rhs = rhs / 255d;
|
||||
}
|
||||
alpha = applyOperator(alpha, rhs, operator);
|
||||
}
|
||||
}
|
||||
return Color.argb(
|
||||
clampChannel(alpha),
|
||||
clampChannel(rgb[0]),
|
||||
clampChannel(rgb[1]),
|
||||
clampChannel(rgb[2]));
|
||||
}
|
||||
|
||||
private double applyOperator(double left, double right, char operator) {
|
||||
switch (operator) {
|
||||
case '+':
|
||||
return left + right;
|
||||
case '-':
|
||||
return left - right;
|
||||
case '*':
|
||||
return left * right;
|
||||
case '/':
|
||||
return right == 0d ? left : left / right;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unsupported operator");
|
||||
}
|
||||
}
|
||||
|
||||
private Operand parseOperand(String operandText) {
|
||||
if (TextUtils.isEmpty(operandText)) {
|
||||
throw new IllegalArgumentException("Missing operand");
|
||||
}
|
||||
if (operandText.charAt(0) == '#') {
|
||||
return parseHexOperand(operandText);
|
||||
}
|
||||
double value;
|
||||
try {
|
||||
value = Double.parseDouble(operandText);
|
||||
return Value.number(Double.parseDouble(value));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Invalid numeric operand", e);
|
||||
throw new IllegalArgumentException("Invalid colour token", e);
|
||||
}
|
||||
return new Operand(new double[] { value, value, value }, 0d, false, false);
|
||||
}
|
||||
|
||||
private Operand parseHexOperand(String raw) {
|
||||
String hex = expandHex(raw);
|
||||
boolean hasAlpha = hex.length() == 8;
|
||||
int offset = hasAlpha ? 2 : 0;
|
||||
double alpha = hasAlpha ? parseHexByte(hex.substring(0, 2)) : 0d;
|
||||
return new Operand(
|
||||
new double[] {
|
||||
parseHexByte(hex.substring(offset, offset + 2)),
|
||||
parseHexByte(hex.substring(offset + 2, offset + 4)),
|
||||
parseHexByte(hex.substring(offset + 4, offset + 6))
|
||||
},
|
||||
alpha,
|
||||
hasAlpha,
|
||||
true);
|
||||
private double[] parseVector(String token) {
|
||||
String value = token != null ? token.trim() : "";
|
||||
if (!value.startsWith("[") || !value.endsWith("]")) {
|
||||
throw new IllegalArgumentException("Invalid vector literal");
|
||||
}
|
||||
String body = value.substring(1, value.length() - 1).trim();
|
||||
if (body.isEmpty()) {
|
||||
throw new IllegalArgumentException("Empty vector literal");
|
||||
}
|
||||
String[] parts = body.split(",");
|
||||
if (parts.length != 3 && parts.length != 4) {
|
||||
throw new IllegalArgumentException("Vector must have three or four elements");
|
||||
}
|
||||
double[] result = new double[CHANNEL_COUNT];
|
||||
int sourceOffset = parts.length == 4 ? 1 : 0;
|
||||
result[CHANNEL_ALPHA] = parts.length == 4 ? parseNumber(parts[0]) : 255d;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
result[CHANNEL_RED + i] = parseNumber(parts[sourceOffset + i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private double parseNumber(String raw) {
|
||||
try {
|
||||
return Double.parseDouble(raw.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Invalid vector number", e);
|
||||
}
|
||||
}
|
||||
|
||||
private ArrayList<String> tokenize(String expression) {
|
||||
ArrayList<String> tokens = new ArrayList<>();
|
||||
String value = expression != null ? expression.trim() : "";
|
||||
StringBuilder current = new StringBuilder();
|
||||
boolean inVector = false;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (inVector) {
|
||||
current.append(c);
|
||||
if (c == ']') {
|
||||
inVector = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '[') {
|
||||
inVector = true;
|
||||
current.append(c);
|
||||
continue;
|
||||
}
|
||||
if (Character.isWhitespace(c)) {
|
||||
appendToken(tokens, current);
|
||||
continue;
|
||||
}
|
||||
current.append(c);
|
||||
}
|
||||
if (inVector) {
|
||||
throw new IllegalArgumentException("Unterminated vector literal");
|
||||
}
|
||||
appendToken(tokens, current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private void appendToken(ArrayList<String> tokens, StringBuilder current) {
|
||||
if (current.length() <= 0) {
|
||||
return;
|
||||
}
|
||||
tokens.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
|
||||
private VariantExpressions splitVariants(String expression) {
|
||||
String value = expression != null ? expression.trim() : "";
|
||||
int split = findVariantSeparator(value);
|
||||
if (split < 0) {
|
||||
return new VariantExpressions(value, "");
|
||||
}
|
||||
return new VariantExpressions(
|
||||
value.substring(0, split).trim(),
|
||||
value.substring(split + 1).trim());
|
||||
}
|
||||
|
||||
private int findVariantSeparator(String expression) {
|
||||
boolean inVector = false;
|
||||
int split = -1;
|
||||
for (int i = 0; i < expression.length(); i++) {
|
||||
char c = expression.charAt(i);
|
||||
if (inVector) {
|
||||
if (c == ']') {
|
||||
inVector = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == '[') {
|
||||
inVector = true;
|
||||
continue;
|
||||
}
|
||||
if (c == ';') {
|
||||
if (split >= 0) {
|
||||
throw new IllegalArgumentException("Colour expression supports one variant separator");
|
||||
}
|
||||
split = i;
|
||||
}
|
||||
}
|
||||
return split;
|
||||
}
|
||||
|
||||
private boolean isDark(int color) {
|
||||
double darkness = 1d
|
||||
- ((0.299d * red(color))
|
||||
+ (0.587d * green(color))
|
||||
+ (0.114d * blue(color))) / 255d;
|
||||
return darkness > 0.5d;
|
||||
}
|
||||
|
||||
private int parseHexColor(String raw) {
|
||||
String hex = expandHex(raw);
|
||||
try {
|
||||
return Color.parseColor("#" + hex);
|
||||
} catch (IllegalArgumentException e) {
|
||||
long parsed = Long.parseLong(hex, 16);
|
||||
if (hex.length() == 6) {
|
||||
parsed |= 0xff000000L;
|
||||
}
|
||||
return (int) parsed;
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Invalid colour literal", e);
|
||||
}
|
||||
}
|
||||
@@ -280,58 +415,197 @@ final class ClockColorResolver {
|
||||
return hex.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private ArrayList<String> splitSegments(String expression) {
|
||||
ArrayList<String> segments = new ArrayList<>();
|
||||
if (expression == null) {
|
||||
return segments;
|
||||
}
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (int i = 0; i < expression.length(); i++) {
|
||||
char c = expression.charAt(i);
|
||||
if (c == '|') {
|
||||
segments.add(current.toString().trim());
|
||||
current.setLength(0);
|
||||
continue;
|
||||
}
|
||||
current.append(c);
|
||||
}
|
||||
segments.add(current.toString().trim());
|
||||
return segments;
|
||||
}
|
||||
|
||||
private boolean isDark(int color) {
|
||||
double darkness = 1d
|
||||
- ((0.299d * Color.red(color))
|
||||
+ (0.587d * Color.green(color))
|
||||
+ (0.114d * Color.blue(color))) / 255d;
|
||||
return darkness > 0.5d;
|
||||
}
|
||||
|
||||
private boolean isHexDigit(char c) {
|
||||
return (c >= '0' && c <= '9')
|
||||
|| (c >= 'a' && c <= 'f')
|
||||
|| (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
private double parseHexByte(String raw) {
|
||||
return Integer.parseInt(raw, 16);
|
||||
private double[] vectorForColor(int color) {
|
||||
return new double[] {
|
||||
alpha(color),
|
||||
red(color),
|
||||
green(color),
|
||||
blue(color)
|
||||
};
|
||||
}
|
||||
|
||||
private int toColor(Value value) {
|
||||
double[] vector = value.vector
|
||||
? value.vectorValues
|
||||
: new double[] {255d, value.number, value.number, value.number};
|
||||
return argb(
|
||||
clampChannel(vector[CHANNEL_ALPHA]),
|
||||
clampChannel(vector[CHANNEL_RED]),
|
||||
clampChannel(vector[CHANNEL_GREEN]),
|
||||
clampChannel(vector[CHANNEL_BLUE]));
|
||||
}
|
||||
|
||||
private int clampChannel(double value) {
|
||||
if (Double.isNaN(value)) {
|
||||
return 0;
|
||||
}
|
||||
if (value == Double.POSITIVE_INFINITY) {
|
||||
return 255;
|
||||
}
|
||||
if (value == Double.NEGATIVE_INFINITY) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(255, (int) Math.round(value)));
|
||||
}
|
||||
|
||||
private static final class Operand {
|
||||
final double[] rgb;
|
||||
final double alpha;
|
||||
final boolean hasAlpha;
|
||||
final boolean isHex;
|
||||
private boolean[] channelsForSuffix(String suffix) {
|
||||
boolean[] channels = new boolean[] {true, true, true, true};
|
||||
if (isEmpty(suffix)) {
|
||||
return channels;
|
||||
}
|
||||
channels = new boolean[] {false, false, false, false};
|
||||
String value = suffix.toUpperCase(Locale.ROOT);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
switch (value.charAt(i)) {
|
||||
case 'A':
|
||||
channels[CHANNEL_ALPHA] = true;
|
||||
break;
|
||||
case 'R':
|
||||
channels[CHANNEL_RED] = true;
|
||||
break;
|
||||
case 'G':
|
||||
channels[CHANNEL_GREEN] = true;
|
||||
break;
|
||||
case 'B':
|
||||
channels[CHANNEL_BLUE] = true;
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Invalid channel suffix");
|
||||
}
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
Operand(double[] rgb, double alpha, boolean hasAlpha, boolean isHex) {
|
||||
this.rgb = rgb;
|
||||
this.alpha = alpha;
|
||||
this.hasAlpha = hasAlpha;
|
||||
this.isHex = isHex;
|
||||
private boolean isChannelSuffix(String suffix) {
|
||||
if (suffix == null) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < suffix.length(); i++) {
|
||||
char c = Character.toUpperCase(suffix.charAt(i));
|
||||
if (c != 'A' && c != 'R' && c != 'G' && c != 'B') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private Value top(ArrayList<Value> stack) {
|
||||
if (stack.isEmpty()) {
|
||||
throw new IllegalArgumentException("Colour stack underflow");
|
||||
}
|
||||
return stack.get(stack.size() - 1);
|
||||
}
|
||||
|
||||
private Value pop(ArrayList<Value> stack) {
|
||||
if (stack.isEmpty()) {
|
||||
throw new IllegalArgumentException("Colour stack underflow");
|
||||
}
|
||||
return stack.remove(stack.size() - 1);
|
||||
}
|
||||
|
||||
private void replaceTop(ArrayList<Value> stack, Value value) {
|
||||
if (stack.isEmpty()) {
|
||||
throw new IllegalArgumentException("Colour stack underflow");
|
||||
}
|
||||
stack.set(stack.size() - 1, value);
|
||||
}
|
||||
|
||||
private boolean isEmpty(String value) {
|
||||
return value == null || value.isEmpty();
|
||||
}
|
||||
|
||||
private int alpha(int color) {
|
||||
return (color >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
private int red(int color) {
|
||||
return (color >>> 16) & 0xff;
|
||||
}
|
||||
|
||||
private int green(int color) {
|
||||
return (color >>> 8) & 0xff;
|
||||
}
|
||||
|
||||
private int blue(int color) {
|
||||
return color & 0xff;
|
||||
}
|
||||
|
||||
private int argb(int alpha, int red, int green, int blue) {
|
||||
return ((alpha & 0xff) << 24)
|
||||
| ((red & 0xff) << 16)
|
||||
| ((green & 0xff) << 8)
|
||||
| (blue & 0xff);
|
||||
}
|
||||
|
||||
private record VariantExpressions(String brightExpression, String darkExpression) {
|
||||
}
|
||||
|
||||
private record Operation(String name, boolean[] channels) {
|
||||
}
|
||||
|
||||
private static final class Value {
|
||||
final boolean vector;
|
||||
final double number;
|
||||
final double[] vectorValues;
|
||||
|
||||
private Value(boolean vector, double number, double[] vectorValues) {
|
||||
this.vector = vector;
|
||||
this.number = number;
|
||||
this.vectorValues = vectorValues;
|
||||
}
|
||||
|
||||
static Value number(double value) {
|
||||
return new Value(false, value, null);
|
||||
}
|
||||
|
||||
static Value vector(double[] values) {
|
||||
if (values == null || values.length != CHANNEL_COUNT) {
|
||||
throw new IllegalArgumentException("Invalid vector value");
|
||||
}
|
||||
return new Value(true, 0d, values.clone());
|
||||
}
|
||||
|
||||
Value copy() {
|
||||
return vector ? vector(vectorValues) : number(number);
|
||||
}
|
||||
|
||||
double[] asVector() {
|
||||
if (vector) {
|
||||
return vectorValues.clone();
|
||||
}
|
||||
return new double[] {number, number, number, number};
|
||||
}
|
||||
|
||||
Value clip(boolean[] channels) {
|
||||
if (!vector) {
|
||||
return number(clipScalar(number));
|
||||
}
|
||||
double[] result = vectorValues.clone();
|
||||
for (int i = 0; i < CHANNEL_COUNT; i++) {
|
||||
if (channels[i]) {
|
||||
result[i] = clipScalar(result[i]);
|
||||
}
|
||||
}
|
||||
return vector(result);
|
||||
}
|
||||
|
||||
private double clipScalar(double value) {
|
||||
if (Double.isNaN(value)) {
|
||||
return 0d;
|
||||
}
|
||||
if (value == Double.POSITIVE_INFINITY) {
|
||||
return 255d;
|
||||
}
|
||||
if (value == Double.NEGATIVE_INFINITY) {
|
||||
return 0d;
|
||||
}
|
||||
return Math.max(0d, Math.min(255d, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+74
-13
@@ -151,7 +151,7 @@ final class ClockPatternParser {
|
||||
if (trimmed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String[] tokens = trimmed.split("\\s+");
|
||||
ArrayList<String> tokens = tokenizeShorthand(trimmed);
|
||||
for (String token : tokens) {
|
||||
if (TextUtils.isEmpty(token)) {
|
||||
continue;
|
||||
@@ -177,18 +177,11 @@ final class ClockPatternParser {
|
||||
continue;
|
||||
}
|
||||
if (token.charAt(0) == '#') {
|
||||
String literal = "<sbt-clock-color value='"
|
||||
+ android.text.TextUtils.htmlEncode(token)
|
||||
+ "'>";
|
||||
ClockMarkupSupport.applyParsedTag(
|
||||
out,
|
||||
tagState,
|
||||
new ClockMarkupSupport.ParsedTag(
|
||||
false,
|
||||
false,
|
||||
"sbt-clock-color",
|
||||
literal,
|
||||
ClockMarkupSupport.TagCategory.FONT_COLOR));
|
||||
openClockColorTag(out, tagState, token);
|
||||
continue;
|
||||
}
|
||||
if (isColorFunction(token)) {
|
||||
openClockColorTag(out, tagState, token.substring(6, token.length() - 1).trim());
|
||||
continue;
|
||||
}
|
||||
if (token.charAt(0) == '@' && token.length() > 1) {
|
||||
@@ -218,6 +211,74 @@ final class ClockPatternParser {
|
||||
}
|
||||
}
|
||||
|
||||
private ArrayList<String> tokenizeShorthand(String content) {
|
||||
ArrayList<String> tokens = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
int parenDepth = 0;
|
||||
int bracketDepth = 0;
|
||||
for (int i = 0; i < content.length(); i++) {
|
||||
char c = content.charAt(i);
|
||||
if (Character.isWhitespace(c) && parenDepth == 0 && bracketDepth == 0) {
|
||||
appendToken(tokens, current);
|
||||
continue;
|
||||
}
|
||||
if (c == '(' && bracketDepth == 0) {
|
||||
parenDepth++;
|
||||
} else if (c == ')' && bracketDepth == 0) {
|
||||
parenDepth--;
|
||||
if (parenDepth < 0) {
|
||||
throw new IllegalArgumentException("Unmatched shorthand parenthesis");
|
||||
}
|
||||
} else if (c == '[') {
|
||||
bracketDepth++;
|
||||
} else if (c == ']') {
|
||||
bracketDepth--;
|
||||
if (bracketDepth < 0) {
|
||||
throw new IllegalArgumentException("Unmatched shorthand bracket");
|
||||
}
|
||||
}
|
||||
current.append(c);
|
||||
}
|
||||
if (parenDepth != 0 || bracketDepth != 0) {
|
||||
throw new IllegalArgumentException("Unclosed shorthand expression");
|
||||
}
|
||||
appendToken(tokens, current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private void appendToken(ArrayList<String> tokens, StringBuilder current) {
|
||||
if (current.length() <= 0) {
|
||||
return;
|
||||
}
|
||||
tokens.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
|
||||
private boolean isColorFunction(String token) {
|
||||
String value = token != null ? token.trim() : "";
|
||||
return value.regionMatches(true, 0, "color(", 0, 6)
|
||||
&& value.endsWith(")");
|
||||
}
|
||||
|
||||
private void openClockColorTag(
|
||||
StringBuilder out,
|
||||
ClockMarkupSupport.TagState tagState,
|
||||
String expression
|
||||
) {
|
||||
String literal = "<sbt-clock-color value='"
|
||||
+ android.text.TextUtils.htmlEncode(expression)
|
||||
+ "'>";
|
||||
ClockMarkupSupport.applyParsedTag(
|
||||
out,
|
||||
tagState,
|
||||
new ClockMarkupSupport.ParsedTag(
|
||||
false,
|
||||
false,
|
||||
"sbt-clock-color",
|
||||
literal,
|
||||
ClockMarkupSupport.TagCategory.FONT_COLOR));
|
||||
}
|
||||
|
||||
private LineBreakToken tryParseLineBreak(String pattern, int index) {
|
||||
if (pattern.startsWith("\\n", index)) {
|
||||
return new LineBreakToken(index + 2, false, ROW_GAP_AUTO);
|
||||
|
||||
@@ -213,10 +213,10 @@
|
||||
<string name="clock_middle_side_right">Right side</string>
|
||||
<string name="clock_custom_format_title">Custom format</string>
|
||||
<string name="clock_custom_format_enabled">Use custom date/time pattern</string>
|
||||
<string name="clock_custom_format_hint">Examples: HH:mm, EEE d MMM HH:mm:ss, {#batterybar big @sans-serif-condensed}HH:mm</string>
|
||||
<string name="clock_custom_format_hint">Examples: HH:mm, EEE d MMM HH:mm:ss, {color(#batterybar 1.2 * ; invRGB 0.5 *RGB) big @sans-serif-condensed}HH:mm</string>
|
||||
<string name="clock_custom_format_help_common">Date/time patterns:\n\u2022 Uses Java SimpleDateFormat-style pattern letters\n\u2022 Common symbols: yyyy yy MMMM MMM MM EEEE EEE dd d HH hh mm ss a\n\nTags:\n\u2022 Supported tags: <i>, <u>, <small>, <big>, <font color=\'...\'>, <font face=\'...\'>\n\u2022 <b> is not supported\n\u2022 Custom font tags: <sans>, <serif>, <mono>, <condensed>\n\u2022 {/font} closes any open <font ...> tag\n\u2022 Opening a new colour or font face automatically closes the previous one</string>
|
||||
<string name="clock_custom_format_help_styling">Shorthand blocks:\n\u2022 {#FA0 big @sans-serif-condensed} is shorthand for opening multiple tags in order\n\u2022 In shorthand: / closes a tag, # sets font colour, @ sets font face\n\u2022 Each whitespace-separated token inside {} becomes its own tag\n\nLine breaks:\n\u2022 Use \\n, {\\n} or {\\n15}; the number is the line-height delta in px\n\u2022 Omit the number to use the default row spacing\n\u2022 { /\\n } style line breaks are written without spaces: {/\\n} or {/\\n15}\n\u2022 Normal line breaks propagate open tags onto the next row\n\u2022 A / line break ends all currently open tags before the next row starts\n\nPipes and grouping:\n\u2022 Maximum two pipes per row; more than two is a syntax error for that row only\n\u2022 Pipes inside single quotes or {} do not split the row\n\u2022 One pipe splits into left and right segments\n\u2022 Two pipes remove the middle section, then split using the outer pipes\n\u2022 Tags are logically continued across pipe splits, even if the middle section is ignored\n\u2022 Rows without pipes are independent; rows with pipes share one common pipe group\n\u2022 A syntax-error row becomes the text Syntax error, with no whole-clock fallback\n\nColours:\n\u2022 Supported colour literals: #RGB, #ARGB, #RRGGBB, #AARRGGBB\n\u2022 {#bright|#dark} means bright text on dark backgrounds, dark text on bright backgrounds\n\u2022 {#batterybar} copies the current battery bar colour\n\u2022 After |, if the next token is not #, the dark variant is built with math\n\u2022 Dark-variant math can start with inv or an operator (+ - * /)\n\u2022 Math runs strictly left to right; there is no operator precedence\n\u2022 Decimal numbers affect R, G and B only; alpha stays unchanged\n\u2022 Hex operands use 00..FF channel values for + and -, and channel/255 scaling for * and /\n\u2022 8-digit hex operands can also affect alpha</string>
|
||||
<string name="clock_custom_format_help_examples">Examples:\n\u2022 <small>EEE d MMM</small> HH:mm\n\u2022 HH<small>:mm</small>\n\u2022 <font color=\'#FA0\'>ss</font>\n\u2022 {#FA0|inv*1.1-4}ss\n\u2022 {#batterybar big u @sans-serif-condensed}HH:mm\n\u2022 HH:mm{\\n12 small}EEE d MMM\n\u2022 HH|mm aligns the pipe position across every piped row\n\u2022 left||right removes the middle section before split alignment\n\u2022 {/font}{/\\n big}HH:mm starts a new row without carrying the previous font tags forward</string>
|
||||
<string name="clock_custom_format_help_styling">Shorthand blocks:\n\u2022 {#FA0 big @sans-serif-condensed} is shorthand for opening multiple tags in order\n\u2022 {#FA0} is shorthand for {color(#FA0)}\n\u2022 In shorthand: / closes a tag, color(...) sets font colour, @ sets font face\n\u2022 Each whitespace-separated token inside {} becomes its own tag, except color(...) which may contain spaces\n\nLine breaks:\n\u2022 Use \\n, {\\n} or {\\n15}; the number is the line-height delta in px\n\u2022 Omit the number to use the default row spacing\n\u2022 { /\\n } style line breaks are written without spaces: {/\\n} or {/\\n15}\n\u2022 Normal line breaks propagate open tags onto the next row\n\u2022 A / line break ends all currently open tags before the next row starts\n\nPipes and grouping:\n\u2022 Maximum two pipes per row; more than two is a syntax error for that row only\n\u2022 Pipes inside single quotes or {} do not split the row\n\u2022 One pipe splits into left and right segments\n\u2022 Two pipes remove the middle section, then split using the outer pipes\n\u2022 Tags are logically continued across pipe splits, even if the middle section is ignored\n\u2022 Rows without pipes are independent; rows with pipes share one common pipe group\n\u2022 A syntax-error row becomes the text Syntax error, with no whole-clock fallback\n\nColours:\n\u2022 Supported operands: #RGB, #ARGB, #RRGGBB, #AARRGGBB, #batterybar, [R,G,B], [A,R,G,B], and numbers\n\u2022 Colour expressions use reverse Polish notation: operands first, operator last\n\u2022 Use ; to separate bright/dark variants; the dark side starts with the raw result from the bright side on the stack\n\u2022 Operators: + - * / ^ inv min max sqrt clip dup\n\u2022 Operators can be channel-filtered, for example +A, *RGB, invRGB, maxRGB, clipA\n\u2022 Final values are clipped to 0..255; a final number becomes opaque grayscale</string>
|
||||
<string name="clock_custom_format_help_examples">Examples:\n\u2022 <small>EEE d MMM</small> HH:mm\n\u2022 HH<small>:mm</small>\n\u2022 <font color=\'#FA0\'>ss</font>\n\u2022 {color(#FA0 ; invRGB 0.8 *RGB)}ss\n\u2022 {color(#batterybar 1.2 * ; invRGB 0.5 *RGB) big u @sans-serif-condensed}HH:mm\n\u2022 {color([255, 255, 170, 0] ; invRGB)}HH:mm\n\u2022 HH:mm{\\n12 small}EEE d MMM\n\u2022 HH|mm aligns the pipe position across every piped row\n\u2022 left||right removes the middle section before split alignment\n\u2022 {/font}{/\\n big}HH:mm starts a new row without carrying the previous font tags forward</string>
|
||||
<string name="clock_font_picker_button">Browse font families</string>
|
||||
<string name="clock_custom_format_help_show">Show pattern instructions</string>
|
||||
<string name="clock_custom_format_help_hide">Hide pattern instructions</string>
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package se.ajpanton.statusbartweak.runtime.features.clock;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class ClockColorResolverTest {
|
||||
private static final int WHITE_REFERENCE = 0xffffffff;
|
||||
private static final int BLACK_REFERENCE = 0xff000000;
|
||||
|
||||
private final ClockColorResolver resolver = new ClockColorResolver();
|
||||
|
||||
@Test
|
||||
public void resolvesSimpleHexVector() {
|
||||
assertEquals(0xffffaa00, resolver.resolveColor(null, "#FA0", WHITE_REFERENCE, 0xffffffff));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void semicolonSeparatesBrightAndDarkRpnVariants() {
|
||||
String expression = "#102030 2 * ; invRGB 0.5 *RGB";
|
||||
|
||||
assertEquals(0xff204060, resolver.resolveColor(null, expression, WHITE_REFERENCE, 0xffffffff));
|
||||
assertEquals(0xff706050, resolver.resolveColor(null, expression, BLACK_REFERENCE, 0xffffffff));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void channelFilteredVectorMathPassesSkippedChannelsFromTopVector() {
|
||||
String expression = "#112233 [128, 1, 2, 3] +R";
|
||||
|
||||
assertEquals(0x80120203, resolver.resolveColor(null, expression, WHITE_REFERENCE, 0xffffffff));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void maxRgbConvertsVectorToNumber() {
|
||||
assertEquals(0xff090909, resolver.resolveColor(null, "[1, 2, 9] maxRGB", WHITE_REFERENCE, 0xffffffff));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void maxThrowsWhenNumberIsOnTopOfVector() {
|
||||
resolver.resolveColor(null, "#123 5 max", WHITE_REFERENCE, 0xffffffff);
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void dupDoesNotAcceptChannelSuffix() {
|
||||
resolver.resolveColor(null, "#123 dupRGB", WHITE_REFERENCE, 0xffffffff);
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void rejectsMoreThanTwoColorVariants() {
|
||||
resolver.resolveColor(null, "#123 ; #456 ; #789", WHITE_REFERENCE, 0xffffffff);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user