Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5deb9ff10 | ||
|
|
07c3444642 | ||
|
|
1d5bcb4e39 | ||
|
|
51d86e8ad1 | ||
|
|
394e1faf24 |
+12
-62
@@ -1,11 +1,9 @@
|
||||
package se.ajpanton.statusbartweak.runtime.layout;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Notification;
|
||||
import android.app.KeyguardManager;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
@@ -13,7 +11,6 @@ import android.graphics.drawable.Drawable;
|
||||
import android.os.Looper;
|
||||
import android.os.PowerManager;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import android.telecom.TelecomManager;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowInsets;
|
||||
@@ -56,6 +53,7 @@ import se.ajpanton.statusbartweak.runtime.render.LockscreenCardsNotificationStri
|
||||
import se.ajpanton.statusbartweak.runtime.notifications.NotificationActiveKeyStore;
|
||||
import se.ajpanton.statusbartweak.runtime.notifications.NotificationUnreadTracker;
|
||||
import se.ajpanton.statusbartweak.runtime.render.OwnedIconHostManager;
|
||||
import se.ajpanton.statusbartweak.runtime.render.StatusChipCallSupport;
|
||||
import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController;
|
||||
import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController.RenderResult;
|
||||
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
|
||||
@@ -2812,8 +2810,9 @@ final class StockLayoutCanvasController {
|
||||
staleViews.add(view);
|
||||
continue;
|
||||
}
|
||||
if (!isTrackableStatusChipSurface(view)
|
||||
&& !isTrackedHiddenStatusChipSource(root, view)) {
|
||||
boolean trackable = isTrackableStatusChipSurface(view);
|
||||
boolean retainedHidden = !trackable && isTrackedHiddenStatusChipSource(root, view);
|
||||
if (!trackable && !retainedHidden) {
|
||||
forgetTrackedStatusChipState(view);
|
||||
staleViews.add(view);
|
||||
continue;
|
||||
@@ -3365,7 +3364,7 @@ final class StockLayoutCanvasController {
|
||||
return visibility != View.VISIBLE
|
||||
&& isTrackedStatusChipWriteTarget(view)
|
||||
&& hasVisibleStatusChipContent(view)
|
||||
&& !isCallStatusChip(view);
|
||||
&& !StatusChipCallSupport.isCallChip(view);
|
||||
}
|
||||
|
||||
private boolean isTrackedStatusChipWriteTarget(View view) {
|
||||
@@ -3868,6 +3867,9 @@ final class StockLayoutCanvasController {
|
||||
if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) {
|
||||
return false;
|
||||
}
|
||||
if (StatusChipCallSupport.isInactiveCallChip(view)) {
|
||||
return false;
|
||||
}
|
||||
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
|
||||
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
|
||||
if (width <= 0 || height <= 0) {
|
||||
@@ -3904,6 +3906,9 @@ final class StockLayoutCanvasController {
|
||||
if (view.getVisibility() != View.VISIBLE) {
|
||||
return false;
|
||||
}
|
||||
if (StatusChipCallSupport.isInactiveCallChip(view)) {
|
||||
return false;
|
||||
}
|
||||
String idName = ViewIdNames.idName(view);
|
||||
if (!"ongoing_activity_capsule".equals(idName)) {
|
||||
return false;
|
||||
@@ -3932,31 +3937,12 @@ final class StockLayoutCanvasController {
|
||||
|| !isStatusChipCandidate(view)) {
|
||||
return false;
|
||||
}
|
||||
if (isCallStatusChip(view)
|
||||
&& view.getVisibility() != View.VISIBLE
|
||||
&& !isPhoneCallActive(view)) {
|
||||
if (StatusChipCallSupport.isInactiveCallChip(view)) {
|
||||
return false;
|
||||
}
|
||||
return hasVisibleStatusChipContent(view);
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // The SystemUI process owns READ_PHONE_STATE; the explicit check below handles other hosts.
|
||||
private boolean isPhoneCallActive(View view) {
|
||||
if (view == null || view.getContext() == null) {
|
||||
return false;
|
||||
}
|
||||
if (view.getContext().checkSelfPermission(Manifest.permission.READ_PHONE_STATE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
TelecomManager telecomManager = view.getContext().getSystemService(TelecomManager.class);
|
||||
return telecomManager != null && telecomManager.isInCall();
|
||||
} catch (RuntimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasVisibleStatusChipContent(View source) {
|
||||
if (!(source instanceof ViewGroup group)) {
|
||||
return false;
|
||||
@@ -3986,42 +3972,6 @@ final class StockLayoutCanvasController {
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isCallStatusChip(View source) {
|
||||
if (source == null) {
|
||||
return false;
|
||||
}
|
||||
ArrayDeque<View> queue = new ArrayDeque<>();
|
||||
queue.add(source);
|
||||
int scanned = 0;
|
||||
while (!queue.isEmpty() && scanned++ < 48) {
|
||||
View view = queue.removeFirst();
|
||||
String className = view.getClass().getName().toLowerCase(Locale.ROOT);
|
||||
String idName = ViewIdNames.idName(view).toLowerCase(Locale.ROOT);
|
||||
if (className.contains("call") || idName.contains("call")) {
|
||||
return true;
|
||||
}
|
||||
if (containsCallToken(view.getContentDescription())) {
|
||||
return true;
|
||||
}
|
||||
if (view instanceof TextView textView && containsCallToken(textView.getText())) {
|
||||
return true;
|
||||
}
|
||||
if (view instanceof ViewGroup childGroup) {
|
||||
for (int i = 0; i < childGroup.getChildCount(); i++) {
|
||||
queue.addLast(childGroup.getChildAt(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean containsCallToken(CharSequence value) {
|
||||
if (value == null || value.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
String text = value.toString().toLowerCase(Locale.ROOT);
|
||||
return text.contains("call") || text.contains("phone");
|
||||
}
|
||||
|
||||
private boolean isStatusChipCandidate(View view) {
|
||||
String className = view.getClass().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package se.ajpanton.statusbartweak.runtime.render;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.text.Spannable;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
|
||||
/** Draws the outline with one colour, then restores the original span colours for the text fill. */
|
||||
final class ClockOutlineTextView extends AppCompatTextView {
|
||||
private int sourcePaddingLeft;
|
||||
private int sourcePaddingTop;
|
||||
private int sourcePaddingRight;
|
||||
private int sourcePaddingBottom;
|
||||
private int outlineColor;
|
||||
private float outlineThicknessPx;
|
||||
private ForegroundColorSpan outlineSpan;
|
||||
|
||||
ClockOutlineTextView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
boolean setSourcePadding(int left, int top, int right, int bottom) {
|
||||
if (sourcePaddingLeft == left
|
||||
&& sourcePaddingTop == top
|
||||
&& sourcePaddingRight == right
|
||||
&& sourcePaddingBottom == bottom) {
|
||||
return false;
|
||||
}
|
||||
sourcePaddingLeft = left;
|
||||
sourcePaddingTop = top;
|
||||
sourcePaddingRight = right;
|
||||
sourcePaddingBottom = bottom;
|
||||
applyPaddedInsets();
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean setOutline(ClockOutlineSpec outline) {
|
||||
int color = outline != null ? outline.color : 0;
|
||||
float thickness = outline != null ? outline.thicknessPx : 0f;
|
||||
if (outlineColor == color && outlineThicknessPx == thickness) {
|
||||
return false;
|
||||
}
|
||||
outlineColor = color;
|
||||
outlineThicknessPx = thickness;
|
||||
outlineSpan = new ForegroundColorSpan(color);
|
||||
applyPaddedInsets();
|
||||
return true;
|
||||
}
|
||||
|
||||
int outlineInsetPx() {
|
||||
return enabled() ? (int) Math.ceil(outlineThicknessPx) : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (!enabled()) {
|
||||
super.onDraw(canvas);
|
||||
return;
|
||||
}
|
||||
Paint paint = getPaint();
|
||||
Paint.Style style = paint.getStyle();
|
||||
float strokeWidth = paint.getStrokeWidth();
|
||||
int color = paint.getColor();
|
||||
ArrayList<SpanState> foregrounds = replaceForegroundColors();
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setStrokeWidth(outlineThicknessPx * 2f);
|
||||
paint.setColor(outlineColor);
|
||||
try {
|
||||
super.onDraw(canvas);
|
||||
} finally {
|
||||
restoreForegroundColors(foregrounds);
|
||||
paint.setStyle(style);
|
||||
paint.setStrokeWidth(strokeWidth);
|
||||
paint.setColor(color);
|
||||
}
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
private ArrayList<SpanState> replaceForegroundColors() {
|
||||
if (!(getText() instanceof Spannable text) || text.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
ForegroundColorSpan[] spans = text.getSpans(0, text.length(), ForegroundColorSpan.class);
|
||||
ArrayList<SpanState> states = new ArrayList<>(spans.length);
|
||||
for (ForegroundColorSpan span : spans) {
|
||||
states.add(new SpanState(
|
||||
span,
|
||||
text.getSpanStart(span),
|
||||
text.getSpanEnd(span),
|
||||
text.getSpanFlags(span)));
|
||||
text.removeSpan(span);
|
||||
}
|
||||
text.setSpan(outlineSpan, 0, text.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return states;
|
||||
}
|
||||
|
||||
private void restoreForegroundColors(ArrayList<SpanState> states) {
|
||||
if (!(getText() instanceof Spannable text) || outlineSpan == null) {
|
||||
return;
|
||||
}
|
||||
text.removeSpan(outlineSpan);
|
||||
if (states == null) {
|
||||
return;
|
||||
}
|
||||
for (SpanState state : states) {
|
||||
text.setSpan(state.span, state.start, state.end, state.flags);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean enabled() {
|
||||
return outlineThicknessPx > 0f && ((outlineColor >>> 24) & 0xff) > 0;
|
||||
}
|
||||
|
||||
private void applyPaddedInsets() {
|
||||
int inset = outlineInsetPx();
|
||||
setPadding(
|
||||
sourcePaddingLeft + inset,
|
||||
sourcePaddingTop + inset,
|
||||
sourcePaddingRight + inset,
|
||||
sourcePaddingBottom + inset);
|
||||
}
|
||||
|
||||
private record SpanState(ForegroundColorSpan span, int start, int end, int flags) {
|
||||
}
|
||||
}
|
||||
+40
-20
@@ -1,7 +1,7 @@
|
||||
package se.ajpanton.statusbartweak.runtime.render;
|
||||
|
||||
import android.text.TextUtils;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -19,6 +19,10 @@ final class ClockProxyRenderer {
|
||||
private final WeakHashMap<ViewGroup, ArrayList<TextView>> rowsByHost = new WeakHashMap<>();
|
||||
|
||||
void render(ViewGroup host, ClockPlacement placement) {
|
||||
render(host, placement, ClockOutlineSpec.NONE);
|
||||
}
|
||||
|
||||
void render(ViewGroup host, ClockPlacement placement, ClockOutlineSpec outline) {
|
||||
if (host == null || placement == null || placement.rows.isEmpty()) {
|
||||
clear(host);
|
||||
return;
|
||||
@@ -36,13 +40,8 @@ final class ClockProxyRenderer {
|
||||
views.clear();
|
||||
hostChanged = true;
|
||||
}
|
||||
while (views.size() > placement.rows.size()) {
|
||||
TextView view = views.remove(views.size() - 1);
|
||||
detachFromParent(view);
|
||||
hostChanged = true;
|
||||
}
|
||||
while (views.size() < placement.rows.size()) {
|
||||
TextView view = new TextView(host.getContext());
|
||||
TextView view = new ClockOutlineTextView(host.getContext());
|
||||
view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
|
||||
views.add(view);
|
||||
host.addView(view);
|
||||
@@ -50,10 +49,13 @@ final class ClockProxyRenderer {
|
||||
}
|
||||
for (int i = 0; i < placement.rows.size(); i++) {
|
||||
ClockRow row = placement.rows.get(i);
|
||||
TextView view = views.get(i);
|
||||
ClockOutlineTextView view = (ClockOutlineTextView) views.get(i);
|
||||
if (copyTextStyleIfChanged(placement.source, view, placement.textSizePx)) {
|
||||
hostChanged = true;
|
||||
}
|
||||
if (view.setOutline(outline)) {
|
||||
hostChanged = true;
|
||||
}
|
||||
if (applyTextColorOverrideIfNeeded(view, placement.textColorOverride)) {
|
||||
hostChanged = true;
|
||||
}
|
||||
@@ -61,7 +63,7 @@ final class ClockProxyRenderer {
|
||||
hostChanged = true;
|
||||
}
|
||||
if (!sameTextAndColorSpans(view.getText(), row.text)) {
|
||||
view.setText(row.text);
|
||||
view.setText(row.text, TextView.BufferType.SPANNABLE);
|
||||
hostChanged = true;
|
||||
}
|
||||
if (!Objects.equals(view.getContentDescription(), placement.contentDescription)) {
|
||||
@@ -76,21 +78,24 @@ final class ClockProxyRenderer {
|
||||
view.setAlpha(1f);
|
||||
hostChanged = true;
|
||||
}
|
||||
int left = placement.bounds.left + row.x;
|
||||
int top = placement.bounds.top + row.y;
|
||||
int inset = view.outlineInsetPx();
|
||||
int left = placement.bounds.left + row.x - inset;
|
||||
int top = placement.bounds.top + row.y - inset;
|
||||
int width = row.width + 2 * inset;
|
||||
int height = row.height + 2 * inset;
|
||||
int scrollX = Math.max(0, row.clipLeft);
|
||||
if (view.getScrollX() != scrollX) {
|
||||
view.setScrollX(scrollX);
|
||||
hostChanged = true;
|
||||
}
|
||||
if (applyTextLayoutParams(host, view, left, top, row.width, row.height)) {
|
||||
if (applyTextLayoutParams(host, view, left, top, width, height)) {
|
||||
hostChanged = true;
|
||||
}
|
||||
if (view.getLeft() != left
|
||||
|| view.getTop() != top
|
||||
|| view.getRight() != left + row.width
|
||||
|| view.getBottom() != top + row.height) {
|
||||
view.layout(left, top, left + row.width, top + row.height);
|
||||
|| view.getRight() != left + width
|
||||
|| view.getBottom() != top + height) {
|
||||
view.layout(left, top, left + width, top + height);
|
||||
hostChanged = true;
|
||||
}
|
||||
}
|
||||
@@ -211,7 +216,13 @@ final class ClockProxyRenderer {
|
||||
target.setHorizontallyScrolling(true);
|
||||
changed = true;
|
||||
}
|
||||
if (target.getPaddingLeft() != source.getPaddingLeft()
|
||||
if (target instanceof ClockOutlineTextView outlineTarget) {
|
||||
changed |= outlineTarget.setSourcePadding(
|
||||
source.getPaddingLeft(),
|
||||
source.getPaddingTop(),
|
||||
source.getPaddingRight(),
|
||||
source.getPaddingBottom());
|
||||
} else if (target.getPaddingLeft() != source.getPaddingLeft()
|
||||
|| target.getPaddingTop() != source.getPaddingTop()
|
||||
|| target.getPaddingRight() != source.getPaddingRight()
|
||||
|| target.getPaddingBottom() != source.getPaddingBottom()) {
|
||||
@@ -234,10 +245,7 @@ final class ClockProxyRenderer {
|
||||
}
|
||||
|
||||
private static boolean applyTextColorOverrideIfNeeded(TextView view, int color) {
|
||||
if (view == null || (color >>> 24) == 0) {
|
||||
return false;
|
||||
}
|
||||
if (view.getCurrentTextColor() == color) {
|
||||
if (view == null || (color >>> 24) == 0 || view.getCurrentTextColor() == color) {
|
||||
return false;
|
||||
}
|
||||
view.setTextColor(color);
|
||||
@@ -276,3 +284,15 @@ final class ClockProxyRenderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClockOutlineSpec {
|
||||
static final ClockOutlineSpec NONE = new ClockOutlineSpec(0, 0f);
|
||||
|
||||
final int color;
|
||||
final float thicknessPx;
|
||||
|
||||
ClockOutlineSpec(int color, float thicknessPx) {
|
||||
this.color = color;
|
||||
this.thicknessPx = thicknessPx;
|
||||
}
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package se.ajpanton.statusbartweak.runtime.render;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.telecom.TelecomManager;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Locale;
|
||||
|
||||
import se.ajpanton.statusbartweak.runtime.ViewIdNames;
|
||||
|
||||
/** Shared call-chip detection for source suppression and snapshot collection. */
|
||||
public final class StatusChipCallSupport {
|
||||
private StatusChipCallSupport() {
|
||||
}
|
||||
|
||||
public static boolean isInactiveCallChip(View source) {
|
||||
return isCallChip(source) && !isPhoneCallActive(source);
|
||||
}
|
||||
|
||||
public static boolean isCallChip(View source) {
|
||||
if (source == null) {
|
||||
return false;
|
||||
}
|
||||
ArrayDeque<View> queue = new ArrayDeque<>();
|
||||
queue.add(source);
|
||||
int scanned = 0;
|
||||
while (!queue.isEmpty() && scanned++ < 48) {
|
||||
View view = queue.removeFirst();
|
||||
String className = view.getClass().getName().toLowerCase(Locale.ROOT);
|
||||
String idName = ViewIdNames.idName(view).toLowerCase(Locale.ROOT);
|
||||
if (className.contains("ongoingcall")
|
||||
|| className.contains("ongoing_call")
|
||||
|| idName.contains("ongoing_call")) {
|
||||
return true;
|
||||
}
|
||||
if (view instanceof ViewGroup childGroup) {
|
||||
for (int i = 0; i < childGroup.getChildCount(); i++) {
|
||||
queue.addLast(childGroup.getChildAt(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
public static boolean isPhoneCallActive(View view) {
|
||||
if (view == null || view.getContext() == null
|
||||
|| view.getContext().checkSelfPermission(Manifest.permission.READ_PHONE_STATE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
TelecomManager telecomManager = view.getContext().getSystemService(TelecomManager.class);
|
||||
return telecomManager != null && telecomManager.isInCall();
|
||||
} catch (RuntimeException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+56
-6
@@ -12,6 +12,7 @@ import java.util.WeakHashMap;
|
||||
|
||||
import se.ajpanton.statusbartweak.platform.SystemUiCapabilities;
|
||||
import se.ajpanton.statusbartweak.runtime.features.clock.ClockLayoutTextFactory;
|
||||
import se.ajpanton.statusbartweak.runtime.features.clock.ClockColorResolver;
|
||||
import se.ajpanton.statusbartweak.runtime.layoutsolver.StatusBarLayoutSolver.Bounds;
|
||||
import se.ajpanton.statusbartweak.runtime.mode.NotificationDisplayStyle;
|
||||
import se.ajpanton.statusbartweak.runtime.mode.SceneKey;
|
||||
@@ -29,6 +30,7 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
private final SnapshotRenderer notificationRenderer;
|
||||
private final SnapshotRenderer chipRenderer;
|
||||
private final ClockProxyRenderer clockRenderer = new ClockProxyRenderer();
|
||||
private final ClockColorResolver clockColorResolver = new ClockColorResolver();
|
||||
private final ClockProxyRenderer carrierRenderer = new ClockProxyRenderer();
|
||||
private final UnlockedStockSourceCollector sourceCollector;
|
||||
private final TextVerticalOffsetScaler textOffsetScaler = new TextVerticalOffsetScaler();
|
||||
@@ -154,7 +156,7 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock);
|
||||
lastLayoutPlanByRoot.put(root, updatedPlan);
|
||||
lastClockSourceGeometryByRoot.put(root, currentClockSourceGeometry);
|
||||
clockRenderer.render(clockHost, updatedClock);
|
||||
renderClock(clockHost, updatedClock, settings);
|
||||
return new RenderResult(false, false, false);
|
||||
}
|
||||
}
|
||||
@@ -181,7 +183,7 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
updatedClock)) {
|
||||
LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock);
|
||||
lastLayoutPlanByRoot.put(root, updatedPlan);
|
||||
clockRenderer.render(clockHost, updatedClock);
|
||||
renderClock(clockHost, updatedClock, settings);
|
||||
return new RenderResult(false, false, false);
|
||||
}
|
||||
}
|
||||
@@ -239,7 +241,7 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
layoutPlan = withCurrentCarrierAppearance(layoutPlan);
|
||||
lastLayoutPlanByRoot.put(root, layoutPlan);
|
||||
if (clockEnabledForScene(settings, scene) && layoutPlan.clockPlacement != null) {
|
||||
clockRenderer.render(clockHost, layoutPlan.clockPlacement);
|
||||
renderClock(clockHost, layoutPlan.clockPlacement, settings);
|
||||
} else {
|
||||
clockRenderer.clear(clockHost);
|
||||
}
|
||||
@@ -315,7 +317,7 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
layoutPlan = updatedPlan;
|
||||
}
|
||||
if (settings.clockEnabledUnlocked && layoutPlan.clockPlacement != null) {
|
||||
clockRenderer.render(clockHost, layoutPlan.clockPlacement);
|
||||
renderClock(clockHost, layoutPlan.clockPlacement, settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +383,7 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
LayoutPlan updatedPlan = layoutPlan.withClockPlacement(updatedClock);
|
||||
lastLayoutPlanByRoot.put(root, updatedPlan);
|
||||
lastClockAppearanceByRoot.put(root, clockAppearanceSignature(updatedClock, referenceColor, settings));
|
||||
clockRenderer.render(clockHost, updatedClock);
|
||||
renderClock(clockHost, updatedClock, settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -654,6 +656,51 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
return statusBarTintTargetEnabled ? StatusBarTintTarget.color() : android.graphics.Color.TRANSPARENT;
|
||||
}
|
||||
|
||||
private void renderClock(
|
||||
ViewGroup clockHost,
|
||||
ClockPlacement placement,
|
||||
SbtSettings settings
|
||||
) {
|
||||
clockRenderer.render(clockHost, placement, clockOutlineSpec(clockHost, placement, settings));
|
||||
}
|
||||
|
||||
private ClockOutlineSpec clockOutlineSpec(
|
||||
ViewGroup host,
|
||||
ClockPlacement placement,
|
||||
SbtSettings settings
|
||||
) {
|
||||
if (host == null
|
||||
|| placement == null
|
||||
|| settings == null
|
||||
|| !settings.clockOutlineEnabled
|
||||
|| settings.clockOutlineThicknessDp <= 0
|
||||
|| settings.clockOutlineColor == null
|
||||
|| settings.clockOutlineColor.trim().isEmpty()) {
|
||||
return ClockOutlineSpec.NONE;
|
||||
}
|
||||
int referenceColor = hasAlpha(placement.textColorOverride)
|
||||
? placement.textColorOverride
|
||||
: placement.source != null
|
||||
? placement.source.getCurrentTextColor()
|
||||
: android.graphics.Color.WHITE;
|
||||
try {
|
||||
int color = clockColorResolver.resolveColor(
|
||||
host.getContext(),
|
||||
settings.clockOutlineColor,
|
||||
referenceColor,
|
||||
android.graphics.Color.TRANSPARENT,
|
||||
ClockColorResolver.Options.clock());
|
||||
if (!hasAlpha(color)) {
|
||||
return ClockOutlineSpec.NONE;
|
||||
}
|
||||
return new ClockOutlineSpec(
|
||||
color,
|
||||
settings.clockOutlineThicknessDp * host.getResources().getDisplayMetrics().density);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return ClockOutlineSpec.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasAlpha(int color) {
|
||||
return (color >>> 24) != 0;
|
||||
}
|
||||
@@ -689,7 +736,10 @@ public final class UnlockedIconSnapshotRenderController {
|
||||
settings.clockShowSeconds,
|
||||
settings.clockShowDatePrefix,
|
||||
settings.clockCustomFormatEnabled,
|
||||
settings.clockCustomFormat);
|
||||
settings.clockCustomFormat,
|
||||
settings.clockOutlineEnabled,
|
||||
settings.clockOutlineThicknessDp,
|
||||
settings.clockOutlineColor);
|
||||
}
|
||||
|
||||
public void clearAll() {
|
||||
|
||||
+7
@@ -1220,6 +1220,7 @@ final class UnlockedStockSourceCollector {
|
||||
|| !view.isAttachedToWindow()
|
||||
|| !ViewGeometry.isDescendantOf(view, root)
|
||||
|| ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)
|
||||
|| StatusChipCallSupport.isInactiveCallChip(view)
|
||||
|| !isStatusChipCandidate(view)
|
||||
|| !hasRenderableStatusChipContent(view)) {
|
||||
return false;
|
||||
@@ -1245,6 +1246,9 @@ final class UnlockedStockSourceCollector {
|
||||
if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) {
|
||||
return false;
|
||||
}
|
||||
if (StatusChipCallSupport.isInactiveCallChip(view)) {
|
||||
return false;
|
||||
}
|
||||
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
|
||||
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
|
||||
if (width <= 0 || height <= 0 || root == null || width >= root.getWidth() / 2) {
|
||||
@@ -1336,6 +1340,9 @@ final class UnlockedStockSourceCollector {
|
||||
if (!allowHidden && view.getAlpha() <= 0f && !hasRenderableStatusChipContent(view)) {
|
||||
return false;
|
||||
}
|
||||
if (StatusChipCallSupport.isInactiveCallChip(view)) {
|
||||
return false;
|
||||
}
|
||||
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
|
||||
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
|
||||
if (width <= 0 || height <= 0 || width >= root.getWidth() / 2) {
|
||||
|
||||
@@ -87,6 +87,11 @@ public final class SbtDefaults {
|
||||
public static final boolean CLOCK_SHOW_DATE_PREFIX_DEFAULT = false;
|
||||
public static final boolean CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT = false;
|
||||
public static final String CLOCK_CUSTOM_FORMAT_DEFAULT = "";
|
||||
public static final boolean CLOCK_OUTLINE_ENABLED_DEFAULT = false;
|
||||
public static final int CLOCK_OUTLINE_THICKNESS_DP_DEFAULT = 1;
|
||||
public static final int CLOCK_OUTLINE_THICKNESS_DP_MIN = 1;
|
||||
public static final int CLOCK_OUTLINE_THICKNESS_DP_MAX = 6;
|
||||
public static final String CLOCK_OUTLINE_COLOR_DEFAULT = "#000 ; #FFF";
|
||||
public static final boolean DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT = false;
|
||||
public static final String DRAWER_CLOCK_CUSTOM_FORMAT_DEFAULT = "";
|
||||
public static final boolean DRAWER_DATE_CUSTOM_FORMAT_ENABLED_DEFAULT = false;
|
||||
|
||||
@@ -213,6 +213,9 @@ public final class SbtSettings {
|
||||
public static final String KEY_CLOCK_SHOW_DATE_PREFIX = "clock_show_date_prefix";
|
||||
public static final String KEY_CLOCK_CUSTOM_FORMAT_ENABLED = "clock_custom_format_enabled";
|
||||
public static final String KEY_CLOCK_CUSTOM_FORMAT = "clock_custom_format";
|
||||
public static final String KEY_CLOCK_OUTLINE_ENABLED = "clock_outline_enabled";
|
||||
public static final String KEY_CLOCK_OUTLINE_THICKNESS_DP = "clock_outline_thickness_dp";
|
||||
public static final String KEY_CLOCK_OUTLINE_COLOR = "clock_outline_color";
|
||||
public static final String KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED =
|
||||
"drawer_clock_custom_format_enabled";
|
||||
public static final String KEY_DRAWER_CLOCK_CUSTOM_FORMAT = "drawer_clock_custom_format";
|
||||
@@ -454,6 +457,9 @@ public final class SbtSettings {
|
||||
public final boolean clockShowDatePrefix;
|
||||
public final boolean clockCustomFormatEnabled;
|
||||
public final String clockCustomFormat;
|
||||
public final boolean clockOutlineEnabled;
|
||||
public final int clockOutlineThicknessDp;
|
||||
public final String clockOutlineColor;
|
||||
public final boolean drawerClockCustomFormatEnabled;
|
||||
public final String drawerClockCustomFormat;
|
||||
public final boolean drawerDateCustomFormatEnabled;
|
||||
@@ -589,6 +595,9 @@ public final class SbtSettings {
|
||||
boolean clockShowDatePrefix,
|
||||
boolean clockCustomFormatEnabled,
|
||||
String clockCustomFormat,
|
||||
boolean clockOutlineEnabled,
|
||||
int clockOutlineThicknessDp,
|
||||
String clockOutlineColor,
|
||||
boolean drawerClockCustomFormatEnabled,
|
||||
String drawerClockCustomFormat,
|
||||
boolean drawerDateCustomFormatEnabled,
|
||||
@@ -762,6 +771,13 @@ public final class SbtSettings {
|
||||
this.clockCustomFormat = clockCustomFormat != null
|
||||
? clockCustomFormat
|
||||
: SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT;
|
||||
this.clockOutlineEnabled = clockOutlineEnabled;
|
||||
this.clockOutlineThicknessDp = Math.max(
|
||||
SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_MIN,
|
||||
Math.min(clockOutlineThicknessDp, SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_MAX));
|
||||
this.clockOutlineColor = clockOutlineColor != null
|
||||
? clockOutlineColor
|
||||
: SbtDefaults.CLOCK_OUTLINE_COLOR_DEFAULT;
|
||||
this.drawerClockCustomFormatEnabled = drawerClockCustomFormatEnabled;
|
||||
this.drawerClockCustomFormat = drawerClockCustomFormat != null
|
||||
? drawerClockCustomFormat
|
||||
@@ -1219,6 +1235,9 @@ public final class SbtSettings {
|
||||
prefs.getBoolean(KEY_CLOCK_SHOW_DATE_PREFIX, SbtDefaults.CLOCK_SHOW_DATE_PREFIX_DEFAULT),
|
||||
prefs.getBoolean(KEY_CLOCK_CUSTOM_FORMAT_ENABLED, SbtDefaults.CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT),
|
||||
prefs.getString(KEY_CLOCK_CUSTOM_FORMAT, SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT),
|
||||
prefs.getBoolean(KEY_CLOCK_OUTLINE_ENABLED, SbtDefaults.CLOCK_OUTLINE_ENABLED_DEFAULT),
|
||||
prefs.getInt(KEY_CLOCK_OUTLINE_THICKNESS_DP, SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_DEFAULT),
|
||||
prefs.getString(KEY_CLOCK_OUTLINE_COLOR, SbtDefaults.CLOCK_OUTLINE_COLOR_DEFAULT),
|
||||
prefs.getBoolean(KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
|
||||
SbtDefaults.DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT),
|
||||
prefs.getString(KEY_DRAWER_CLOCK_CUSTOM_FORMAT,
|
||||
|
||||
@@ -155,6 +155,15 @@ public class SbtSettingsProvider extends ContentProvider {
|
||||
prefs.getBoolean(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT_ENABLED, SbtDefaults.CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT));
|
||||
out.putString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT,
|
||||
prefs.getString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT, SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT));
|
||||
out.putBoolean(SbtSettings.KEY_CLOCK_OUTLINE_ENABLED,
|
||||
prefs.getBoolean(SbtSettings.KEY_CLOCK_OUTLINE_ENABLED,
|
||||
SbtDefaults.CLOCK_OUTLINE_ENABLED_DEFAULT));
|
||||
out.putInt(SbtSettings.KEY_CLOCK_OUTLINE_THICKNESS_DP,
|
||||
prefs.getInt(SbtSettings.KEY_CLOCK_OUTLINE_THICKNESS_DP,
|
||||
SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_DEFAULT));
|
||||
out.putString(SbtSettings.KEY_CLOCK_OUTLINE_COLOR,
|
||||
prefs.getString(SbtSettings.KEY_CLOCK_OUTLINE_COLOR,
|
||||
SbtDefaults.CLOCK_OUTLINE_COLOR_DEFAULT));
|
||||
out.putBoolean(SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
|
||||
prefs.getBoolean(
|
||||
SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.fragment.app.Fragment;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.card.MaterialCardView;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.slider.Slider;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
@@ -93,6 +94,11 @@ public final class ClockFragment extends Fragment {
|
||||
EditText customFormatInput = root.findViewById(R.id.clock_custom_format_input);
|
||||
ClockPatternPreviewView customFormatPreview =
|
||||
root.findViewById(R.id.clock_custom_format_preview);
|
||||
CheckBox clockOutlineEnabled = root.findViewById(R.id.clock_outline_enabled);
|
||||
View clockOutlineContainer = root.findViewById(R.id.clock_outline_container);
|
||||
Slider clockOutlineThickness = root.findViewById(R.id.clock_outline_thickness);
|
||||
TextView clockOutlineThicknessLabel = root.findViewById(R.id.clock_outline_thickness_value);
|
||||
EditText clockOutlineColor = root.findViewById(R.id.clock_outline_color);
|
||||
MaterialCardView drawerClockCustomFormatCard =
|
||||
root.findViewById(R.id.drawer_clock_custom_format_card);
|
||||
CheckBox drawerClockCustomFormatEnabled =
|
||||
@@ -129,6 +135,12 @@ public final class ClockFragment extends Fragment {
|
||||
String customFormatValue = prefs.getString(
|
||||
SbtSettings.KEY_CLOCK_CUSTOM_FORMAT,
|
||||
SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT);
|
||||
int clockOutlineThicknessDp = prefs.getInt(
|
||||
SbtSettings.KEY_CLOCK_OUTLINE_THICKNESS_DP,
|
||||
SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_DEFAULT);
|
||||
String clockOutlineColorValue = prefs.getString(
|
||||
SbtSettings.KEY_CLOCK_OUTLINE_COLOR,
|
||||
SbtDefaults.CLOCK_OUTLINE_COLOR_DEFAULT);
|
||||
boolean drawerClockCustomFormatEnabledValue = prefs.getBoolean(
|
||||
SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
|
||||
SbtDefaults.DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT);
|
||||
@@ -160,6 +172,15 @@ public final class ClockFragment extends Fragment {
|
||||
customFormatValue,
|
||||
18f,
|
||||
true);
|
||||
bindClockOutline(
|
||||
prefs,
|
||||
clockOutlineEnabled,
|
||||
clockOutlineContainer,
|
||||
clockOutlineThickness,
|
||||
clockOutlineThicknessLabel,
|
||||
clockOutlineThicknessDp,
|
||||
clockOutlineColor,
|
||||
clockOutlineColorValue);
|
||||
bindCustomFormat(
|
||||
prefs,
|
||||
drawerClockCustomFormatCard,
|
||||
@@ -218,6 +239,69 @@ public final class ClockFragment extends Fragment {
|
||||
return root;
|
||||
}
|
||||
|
||||
private void bindClockOutline(
|
||||
SharedPreferences prefs,
|
||||
CheckBox enabledView,
|
||||
View container,
|
||||
Slider thicknessView,
|
||||
TextView thicknessValueView,
|
||||
int thicknessDp,
|
||||
EditText colorView,
|
||||
String colorExpression
|
||||
) {
|
||||
if (enabledView == null || thicknessView == null || colorView == null) {
|
||||
return;
|
||||
}
|
||||
int clampedThickness = Math.max(
|
||||
SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_MIN,
|
||||
Math.min(thicknessDp, SbtDefaults.CLOCK_OUTLINE_THICKNESS_DP_MAX));
|
||||
enabledView.setChecked(prefs.getBoolean(
|
||||
SbtSettings.KEY_CLOCK_OUTLINE_ENABLED,
|
||||
SbtDefaults.CLOCK_OUTLINE_ENABLED_DEFAULT));
|
||||
thicknessView.setValue(clampedThickness);
|
||||
updateClockOutlineThicknessLabel(thicknessValueView, clampedThickness);
|
||||
colorView.setText(colorExpression != null ? colorExpression : "");
|
||||
updateCustomFormatVisibility(container, enabledView.isChecked());
|
||||
enabledView.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
updateCustomFormatVisibility(container, isChecked);
|
||||
prefs.edit().putBoolean(SbtSettings.KEY_CLOCK_OUTLINE_ENABLED, isChecked).apply();
|
||||
SbtSettings.ensureReadable(requireContext());
|
||||
});
|
||||
thicknessView.addOnChangeListener((slider, value, fromUser) -> {
|
||||
int thickness = Math.round(value);
|
||||
updateClockOutlineThicknessLabel(thicknessValueView, thickness);
|
||||
if (!fromUser) {
|
||||
return;
|
||||
}
|
||||
prefs.edit().putInt(SbtSettings.KEY_CLOCK_OUTLINE_THICKNESS_DP, thickness).apply();
|
||||
SbtSettings.ensureReadable(requireContext());
|
||||
});
|
||||
colorView.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
prefs.edit()
|
||||
.putString(SbtSettings.KEY_CLOCK_OUTLINE_COLOR,
|
||||
s != null ? s.toString() : "")
|
||||
.apply();
|
||||
SbtSettings.ensureReadable(requireContext());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateClockOutlineThicknessLabel(TextView view, int thicknessDp) {
|
||||
if (view != null) {
|
||||
view.setText(getString(R.string.clock_outline_thickness_value, thicknessDp));
|
||||
}
|
||||
}
|
||||
|
||||
private void updateCustomFormatVisibility(View customFormatContainer, boolean enabled) {
|
||||
if (customFormatContainer == null) {
|
||||
return;
|
||||
|
||||
@@ -79,6 +79,71 @@
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
app:cardUseCompatPadding="true"
|
||||
app:strokeColor="@color/sbt_card_outline"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.03"
|
||||
android:text="@string/clock_outline_title"
|
||||
android:textAllCaps="true"
|
||||
android:textAppearance="?attr/textAppearanceSubtitle1"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/clock_outline_enabled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/clock_outline_enabled" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/clock_outline_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/clock_outline_thickness_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textAppearance="?attr/textAppearanceBody1" />
|
||||
|
||||
<com.google.android.material.slider.Slider
|
||||
android:id="@+id/clock_outline_thickness"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:valueFrom="1"
|
||||
android:valueTo="6"
|
||||
android:stepSize="1" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/clock_outline_color"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/clock_outline_color_hint"
|
||||
android:importantForAutofill="no"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:singleLine="true" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/drawer_clock_custom_format_card"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -212,6 +212,10 @@
|
||||
<string name="clock_custom_format_drawer_date_title">Drawer date</string>
|
||||
<string name="clock_custom_format_tools_title">Tools and instructions</string>
|
||||
<string name="clock_custom_format_enabled">Enable custom date/time pattern</string>
|
||||
<string name="clock_outline_title">Statusbar clock outline</string>
|
||||
<string name="clock_outline_enabled">Add outline</string>
|
||||
<string name="clock_outline_thickness_value">Thickness: %1$d dp</string>
|
||||
<string name="clock_outline_color_hint">Outline colour, for example #000 ; #FFF</string>
|
||||
<string name="drawer_clock_custom_format_enabled">Enable custom date/time pattern</string>
|
||||
<string name="drawer_date_custom_format_enabled">Enable custom date/time pattern</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>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Editable status-bar layout playground.
|
||||
|
||||
Run from this directory with ``python3 playground.py``. Everything below is
|
||||
intended to be edited freely; the model and renderer live in separate files.
|
||||
|
||||
Coordinates use the same convention as the module's layout solver:
|
||||
horizontal zero is the status bar's left edge and vertical zero is its bottom.
|
||||
"""
|
||||
|
||||
from statusbar_lab import StatusBarLab, containers_overlap, vertically_overlaps
|
||||
from statusbar_renderer import render
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Virtual status-bar settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STATUSBAR_WIDTH = 1200
|
||||
STATUSBAR_HEIGHT = 100
|
||||
|
||||
# These are inputs for the algorithm you will write. The playground itself
|
||||
# does not apply them automatically.
|
||||
EDGE_PADDING = 0
|
||||
CONTAINER_PADDING = 8
|
||||
CAMERA_PADDING = 0
|
||||
TEXT_HORIZONTAL_PADDING = 6
|
||||
ICON_DOT_GAP = 0
|
||||
DOT_WIDTH_FACTOR = 0.75
|
||||
|
||||
# Keep these in the same terms as the module settings. They are intentionally
|
||||
# passive until the positioning/truncation algorithm below uses them.
|
||||
CONTAINER_ORDER = ["clock", "chip", "carrier", "notification", "status"]
|
||||
SHRINK_ORDER = ["notification", "status", "chip", "carrier"]
|
||||
|
||||
# Rendering scale only affects the visualizer, never the simulated geometry.
|
||||
RENDER_SCALE = 1.0
|
||||
VERTICAL_RENDER_SCALE = 2.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LAB = StatusBarLab()
|
||||
|
||||
# These lists remain valid when items are added. Clock text lines belong to
|
||||
# one CLOCK group because the group moves them by their relative x offsets.
|
||||
CLOCK = LAB.clock
|
||||
CLOCK_LINES = CLOCK.lines
|
||||
NOTIFICATIONS = LAB.notifications
|
||||
STATUSES = LAB.statuses
|
||||
CHIPS = LAB.chips
|
||||
CARRIERS = LAB.carriers
|
||||
CAMERAS = LAB.cameras
|
||||
|
||||
|
||||
def _sync_settings() -> None:
|
||||
LAB.text_horizontal_padding = TEXT_HORIZONTAL_PADDING
|
||||
LAB.icon_dot_gap = ICON_DOT_GAP
|
||||
LAB.dot_width_factor = DOT_WIDTH_FACTOR
|
||||
|
||||
|
||||
def AddClock(
|
||||
text: str,
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
horizontal_offset: float = 0,
|
||||
position: str = "left",
|
||||
cutout_side: str = "left",
|
||||
):
|
||||
"""Add one text line to the single movable CLOCK group.
|
||||
|
||||
Horizontal offsets are normalized relative to all clock lines. Therefore
|
||||
offsets ``5, 10, -20`` produce the same clock geometry as ``105, 110, 80``.
|
||||
``position`` is ``left``, ``middle`` or ``right``; ``cutout_side`` is the
|
||||
fallback side for a middle clock when a camera splits the status bar.
|
||||
"""
|
||||
_sync_settings()
|
||||
return CLOCK.add_line(text, height, vertical_offset, horizontal_offset, position, cutout_side)
|
||||
|
||||
|
||||
def AddNotification(
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
position: str = "left",
|
||||
cutout_side: str = "left",
|
||||
direction: str = "right",
|
||||
):
|
||||
_sync_settings()
|
||||
return LAB.add_notification(height, vertical_offset, position, cutout_side, direction)
|
||||
|
||||
|
||||
def SetNotificationIcons(number_of_notifications: int) -> None:
|
||||
"""Replace the notification-icon pool and empty every notification container."""
|
||||
LAB.configure_notification_icons(number_of_notifications)
|
||||
|
||||
|
||||
def AddStatus(
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
position: str = "right",
|
||||
cutout_side: str = "right",
|
||||
direction: str = "left",
|
||||
):
|
||||
_sync_settings()
|
||||
return LAB.add_status(height, vertical_offset, position, cutout_side, direction)
|
||||
|
||||
|
||||
def SetStatusIcons(number_of_icons: int, widths: list[float] | None = None) -> None:
|
||||
"""Replace the status-icon pool and empty every status container.
|
||||
|
||||
Missing widths are padded using the first status container's height. Add
|
||||
status containers before calling this function when relying on that default.
|
||||
"""
|
||||
LAB.configure_status_icons(number_of_icons, widths or [])
|
||||
|
||||
|
||||
def AddChip(
|
||||
text: str,
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
position: str = "left",
|
||||
cutout_side: str = "left",
|
||||
):
|
||||
_sync_settings()
|
||||
return LAB.add_chip(text, height, vertical_offset, position, cutout_side)
|
||||
|
||||
|
||||
def AddCarrier(
|
||||
text: str,
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
position: str = "left",
|
||||
cutout_side: str = "left",
|
||||
):
|
||||
_sync_settings()
|
||||
return LAB.add_carrier(text, height, vertical_offset, position, cutout_side)
|
||||
|
||||
|
||||
def AddCamera(width: float, height: float, horizontal_position: float, vertical_position: float):
|
||||
return LAB.add_camera(width, height, horizontal_position, vertical_position)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simulated app settings: add or remove as many containers as you need.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AddClock("12:34:56", height=40, vertical_offset=0, horizontal_offset=0)
|
||||
AddClock("Mon, 1 Jan", height=24, vertical_offset=42, horizontal_offset=10)
|
||||
|
||||
AddNotification(height=32, vertical_offset=0)
|
||||
AddNotification(height=32, vertical_offset=40)
|
||||
SetNotificationIcons(8)
|
||||
|
||||
AddStatus(height=30, vertical_offset=0)
|
||||
AddStatus(height=30, vertical_offset=38)
|
||||
SetStatusIcons(6, [20, 30, 30, 45])
|
||||
|
||||
AddChip("Navigation 12:34", height=38, vertical_offset=0)
|
||||
AddCarrier("Example carrier", height=30, vertical_offset=45)
|
||||
|
||||
AddCamera(width=90, height=100, horizontal_position=555, vertical_position=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Positioning and truncation algorithm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# All containers currently start at horizontal position zero. Add your layout
|
||||
# algorithm here. Useful operations include:
|
||||
#
|
||||
# CLOCK.set_left(EDGE_PADDING)
|
||||
# NOTIFICATIONS[0].set_right(STATUSBAR_WIDTH - EDGE_PADDING)
|
||||
# CHIPS[0].set_width(120) # clock widths cannot be overridden
|
||||
# NOTIFICATIONS[0].add_icons(3) # takes IDs from the shared pool
|
||||
# NOTIFICATIONS[0].return_icons(1) # returns the newest assigned ID
|
||||
# NOTIFICATIONS[0].set_dot(True)
|
||||
# CLOCK.right_edge_between(10, 50) # ignores clock lines outside this band
|
||||
# containers_overlap(CLOCK, CHIPS[0])
|
||||
# vertically_overlaps(NOTIFICATIONS[0].bounds, CHIPS[0].bounds)
|
||||
|
||||
|
||||
# Keep the renderer call at the end so the editable scenario and algorithm stay
|
||||
# together above it.
|
||||
_sync_settings()
|
||||
render(LAB, STATUSBAR_WIDTH, STATUSBAR_HEIGHT, RENDER_SCALE, VERTICAL_RENDER_SCALE)
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Container model for the status-bar layout playground.
|
||||
|
||||
This module deliberately contains no placement policy. ``playground.py`` owns
|
||||
the scenario and is the only file intended for interactive editing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
def text_width(text: str, height: float, horizontal_padding: float) -> float:
|
||||
"""Use stable approximate glyph widths so geometry is available before Tk starts."""
|
||||
narrow = set(" ilI.,:;!|'`")
|
||||
wide = set("MW@#%&")
|
||||
units = sum(0.35 if char in narrow else 0.9 if char in wide else 0.62 for char in text)
|
||||
return max(0.0, units * height + horizontal_padding * 2)
|
||||
|
||||
|
||||
def _bounds_share_vertical_space(first: "Bounds", second: "Bounds") -> bool:
|
||||
return first.bottom < second.top and second.bottom < first.top
|
||||
|
||||
|
||||
def rectangles_overlap(first: "Bounds", second: "Bounds") -> bool:
|
||||
return (
|
||||
_bounds_share_vertical_space(first, second)
|
||||
and first.left < second.right
|
||||
and second.left < first.right
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bounds:
|
||||
left: float
|
||||
bottom: float
|
||||
right: float
|
||||
top: float
|
||||
|
||||
|
||||
def vertically_overlaps(
|
||||
first: "Bounds | Container | ClockGroup",
|
||||
second: "Bounds | Container | ClockGroup",
|
||||
) -> bool:
|
||||
"""Return whether two containers can collide through horizontal movement.
|
||||
|
||||
A composite clock is expanded to its individual lines, so protruding clock
|
||||
text outside the tested vertical band cannot produce a false collision.
|
||||
"""
|
||||
first_items = _collision_items(first)
|
||||
second_items = _collision_items(second)
|
||||
return any(
|
||||
_bounds_share_vertical_space(first_item.bounds, second_item.bounds)
|
||||
for first_item in first_items
|
||||
for second_item in second_items
|
||||
)
|
||||
|
||||
|
||||
def shares_vertical_space(
|
||||
first: "Bounds | Container | ClockGroup",
|
||||
second: "Bounds | Container | ClockGroup",
|
||||
) -> bool:
|
||||
return vertically_overlaps(first, second)
|
||||
|
||||
|
||||
class Container:
|
||||
"""A mutable, bottom-origin status-bar rectangle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lab: "StatusBarLab",
|
||||
kind: str,
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
position: str = "left",
|
||||
cutout_side: str = "left",
|
||||
) -> None:
|
||||
self.lab = lab
|
||||
self.kind = kind
|
||||
self.height = float(height)
|
||||
self.y = float(vertical_offset)
|
||||
self.position = position
|
||||
self.cutout_side = cutout_side
|
||||
self.x = 0.0
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def left(self) -> float:
|
||||
return self.x
|
||||
|
||||
@property
|
||||
def right(self) -> float:
|
||||
return self.x + self.width
|
||||
|
||||
@property
|
||||
def bottom(self) -> float:
|
||||
return self.y
|
||||
|
||||
@property
|
||||
def top(self) -> float:
|
||||
return self.y + self.height
|
||||
|
||||
@property
|
||||
def bounds(self) -> Bounds:
|
||||
return Bounds(self.left, self.bottom, self.right, self.top)
|
||||
|
||||
def set_left(self, left: float) -> None:
|
||||
self.x = float(left)
|
||||
|
||||
def set_right(self, right: float) -> None:
|
||||
self.x = float(right) - self.width
|
||||
|
||||
def shift_x(self, amount: float) -> None:
|
||||
self.x += float(amount)
|
||||
|
||||
def vertically_overlaps(self, other: "Bounds") -> bool:
|
||||
return vertically_overlaps(self.bounds, other)
|
||||
|
||||
def overlaps(self, other: "Container | ClockGroup") -> bool:
|
||||
return containers_overlap(self, other)
|
||||
|
||||
|
||||
class TextContainer(Container):
|
||||
def __init__(self, text: str, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.text = text
|
||||
self._width_override: Optional[float] = None
|
||||
|
||||
@property
|
||||
def natural_width(self) -> float:
|
||||
return text_width(self.text, self.height, self.lab.text_horizontal_padding)
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.natural_width if self._width_override is None else self._width_override
|
||||
|
||||
def set_width(self, width: float) -> None:
|
||||
self._width_override = max(0.0, float(width))
|
||||
|
||||
def restore_natural_width(self) -> None:
|
||||
self._width_override = None
|
||||
|
||||
|
||||
class ClockLine(TextContainer):
|
||||
"""A clock line whose width always follows its text."""
|
||||
|
||||
def __init__(self, horizontal_offset: float, text: str, *args, **kwargs) -> None:
|
||||
super().__init__(text, *args, **kwargs)
|
||||
self.raw_horizontal_offset = float(horizontal_offset)
|
||||
self.relative_x = 0.0
|
||||
|
||||
def set_width(self, width: float) -> None:
|
||||
raise ValueError("Clock line widths are derived from their text and cannot be changed.")
|
||||
|
||||
|
||||
class ClockGroup:
|
||||
"""One movable clock made from one or more independently-sized text lines."""
|
||||
|
||||
def __init__(self, lab: "StatusBarLab") -> None:
|
||||
self.lab = lab
|
||||
self.lines: list[ClockLine] = []
|
||||
self.x = 0.0
|
||||
|
||||
def add_line(
|
||||
self,
|
||||
text: str,
|
||||
height: float,
|
||||
vertical_offset: float = 0,
|
||||
horizontal_offset: float = 0,
|
||||
position: str = "left",
|
||||
cutout_side: str = "left",
|
||||
) -> ClockLine:
|
||||
line = ClockLine(
|
||||
horizontal_offset,
|
||||
text,
|
||||
self.lab,
|
||||
"clock",
|
||||
height,
|
||||
vertical_offset,
|
||||
position,
|
||||
cutout_side,
|
||||
)
|
||||
self.lines.append(line)
|
||||
self._apply_relative_offsets()
|
||||
return line
|
||||
|
||||
def _apply_relative_offsets(self) -> None:
|
||||
if not self.lines:
|
||||
return
|
||||
origin = min(line.raw_horizontal_offset for line in self.lines)
|
||||
for line in self.lines:
|
||||
line.relative_x = line.raw_horizontal_offset - origin
|
||||
line.x = self.x + line.relative_x
|
||||
|
||||
@property
|
||||
def left(self) -> float:
|
||||
return min((line.left for line in self.lines), default=self.x)
|
||||
|
||||
@property
|
||||
def right(self) -> float:
|
||||
return max((line.right for line in self.lines), default=self.x)
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self.right - self.left
|
||||
|
||||
def set_left(self, left: float) -> None:
|
||||
self.x += float(left) - self.left
|
||||
self._apply_relative_offsets()
|
||||
|
||||
def set_right(self, right: float) -> None:
|
||||
self.x += float(right) - self.right
|
||||
self._apply_relative_offsets()
|
||||
|
||||
def shift_x(self, amount: float) -> None:
|
||||
self.x += float(amount)
|
||||
self._apply_relative_offsets()
|
||||
|
||||
def bounds_between(self, bottom: float, top: float) -> Optional[Bounds]:
|
||||
"""Return clock bounds limited to lines that occupy ``bottom..top``."""
|
||||
band = Bounds(float("-inf"), bottom, float("inf"), top)
|
||||
lines = [line for line in self.lines if vertically_overlaps(line.bounds, band)]
|
||||
if not lines:
|
||||
return None
|
||||
return Bounds(
|
||||
min(line.left for line in lines),
|
||||
min(line.bottom for line in lines),
|
||||
max(line.right for line in lines),
|
||||
max(line.top for line in lines),
|
||||
)
|
||||
|
||||
def left_edge_between(self, bottom: float, top: float) -> Optional[float]:
|
||||
bounds = self.bounds_between(bottom, top)
|
||||
return bounds.left if bounds is not None else None
|
||||
|
||||
def right_edge_between(self, bottom: float, top: float) -> Optional[float]:
|
||||
bounds = self.bounds_between(bottom, top)
|
||||
return bounds.right if bounds is not None else None
|
||||
|
||||
def overlaps(self, other: Container | "ClockGroup") -> bool:
|
||||
return containers_overlap(self, other)
|
||||
|
||||
|
||||
class IconPool:
|
||||
def __init__(self, kind: str) -> None:
|
||||
self.kind = kind
|
||||
self.widths: list[Optional[float]] = []
|
||||
self.available_ids: list[int] = []
|
||||
|
||||
def configure(self, count: int, widths: Iterable[float]) -> None:
|
||||
count = max(0, int(count))
|
||||
supplied: list[Optional[float]] = [float(width) for width in widths][:count]
|
||||
supplied.extend([None] * (count - len(supplied)))
|
||||
self.widths = supplied
|
||||
self.available_ids = list(range(count))
|
||||
|
||||
def take(self, count: int) -> list[int]:
|
||||
count = max(0, int(count))
|
||||
assigned = self.available_ids[:count]
|
||||
del self.available_ids[:len(assigned)]
|
||||
return assigned
|
||||
|
||||
def release(self, ids: Iterable[int]) -> None:
|
||||
self.available_ids = sorted(set(self.available_ids).union(ids))
|
||||
|
||||
def width_for(self, icon_id: int, fallback_width: float) -> float:
|
||||
width = self.widths[icon_id]
|
||||
return fallback_width if width is None else width
|
||||
|
||||
|
||||
class IconContainer(Container):
|
||||
def __init__(self, pool: IconPool, direction: str = "right", *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if direction not in ("left", "right"):
|
||||
raise ValueError("Icon direction must be 'left' or 'right'.")
|
||||
self.pool = pool
|
||||
self.direction = direction
|
||||
self.icon_ids: list[int] = []
|
||||
self.dot = False
|
||||
|
||||
@property
|
||||
def icon_count(self) -> int:
|
||||
return len(self.icon_ids)
|
||||
|
||||
@property
|
||||
def start_number(self) -> Optional[int]:
|
||||
return self.icon_ids[0] if self.icon_ids else None
|
||||
|
||||
@property
|
||||
def dot_width(self) -> float:
|
||||
return self.height * self.lab.dot_width_factor
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
icon_width = sum(self.icon_width(icon_id) for icon_id in self.icon_ids)
|
||||
if not self.dot:
|
||||
return icon_width
|
||||
gap = self.lab.icon_dot_gap if self.icon_ids else 0.0
|
||||
return icon_width + gap + self.dot_width
|
||||
|
||||
def add_icons(self, count: int) -> int:
|
||||
assigned = self.pool.take(count)
|
||||
self.icon_ids.extend(assigned)
|
||||
return len(assigned)
|
||||
|
||||
def return_icons(self, count: int) -> int:
|
||||
count = min(max(0, int(count)), len(self.icon_ids))
|
||||
returned = self.icon_ids[-count:] if count else []
|
||||
if count:
|
||||
del self.icon_ids[-count:]
|
||||
self.pool.release(returned)
|
||||
return len(returned)
|
||||
|
||||
def clear_icons(self) -> None:
|
||||
self.return_icons(len(self.icon_ids))
|
||||
|
||||
def set_dot(self, enabled: bool) -> None:
|
||||
self.dot = bool(enabled)
|
||||
|
||||
def toggle_dot(self) -> bool:
|
||||
self.dot = not self.dot
|
||||
return self.dot
|
||||
|
||||
def segments(self) -> list[tuple[str, Optional[int], float]]:
|
||||
icons = [("icon", icon_id, self.icon_width(icon_id)) for icon_id in self.icon_ids]
|
||||
dot = [("dot", None, self.dot_width)] if self.dot else []
|
||||
if self.dot and self.icon_ids and self.lab.icon_dot_gap > 0:
|
||||
dot.insert(0, ("gap", None, self.lab.icon_dot_gap))
|
||||
return icons + dot if self.direction == "right" else dot + list(reversed(icons))
|
||||
|
||||
def icon_width(self, icon_id: int) -> float:
|
||||
return self.pool.width_for(icon_id, self.height)
|
||||
|
||||
|
||||
class CameraCutout(Container):
|
||||
def __init__(self, lab: "StatusBarLab", width: float, height: float, horizontal_position: float, vertical_position: float) -> None:
|
||||
super().__init__(lab, "camera", height, vertical_position)
|
||||
self._width = float(width)
|
||||
self.x = float(horizontal_position)
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
return self._width
|
||||
|
||||
def set_width(self, width: float) -> None:
|
||||
self._width = max(0.0, float(width))
|
||||
|
||||
|
||||
class StatusBarLab:
|
||||
def __init__(self) -> None:
|
||||
self.text_horizontal_padding = 6.0
|
||||
self.icon_dot_gap = 0.0
|
||||
self.dot_width_factor = 0.75
|
||||
self.clock = ClockGroup(self)
|
||||
self.notifications: list[IconContainer] = []
|
||||
self.statuses: list[IconContainer] = []
|
||||
self.chips: list[TextContainer] = []
|
||||
self.carriers: list[TextContainer] = []
|
||||
self.cameras: list[CameraCutout] = []
|
||||
self.notification_pool = IconPool("notification")
|
||||
self.status_pool = IconPool("status")
|
||||
|
||||
def add_notification(self, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left", direction: str = "right") -> IconContainer:
|
||||
item = IconContainer(self.notification_pool, direction, self, "notification", height, vertical_offset, position, cutout_side)
|
||||
self.notifications.append(item)
|
||||
return item
|
||||
|
||||
def add_status(self, height: float, vertical_offset: float = 0, position: str = "right", cutout_side: str = "right", direction: str = "left") -> IconContainer:
|
||||
item = IconContainer(self.status_pool, direction, self, "status", height, vertical_offset, position, cutout_side)
|
||||
self.statuses.append(item)
|
||||
return item
|
||||
|
||||
def add_chip(self, text: str, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left") -> TextContainer:
|
||||
item = TextContainer(text, self, "chip", height, vertical_offset, position, cutout_side)
|
||||
self.chips.append(item)
|
||||
return item
|
||||
|
||||
def add_carrier(self, text: str, height: float, vertical_offset: float = 0, position: str = "left", cutout_side: str = "left") -> TextContainer:
|
||||
item = TextContainer(text, self, "carrier", height, vertical_offset, position, cutout_side)
|
||||
self.carriers.append(item)
|
||||
return item
|
||||
|
||||
def add_camera(self, width: float, height: float, horizontal_position: float, vertical_position: float) -> CameraCutout:
|
||||
item = CameraCutout(self, width, height, horizontal_position, vertical_position)
|
||||
self.cameras.append(item)
|
||||
return item
|
||||
|
||||
def configure_notification_icons(self, count: int) -> None:
|
||||
self.notification_pool.configure(count, [])
|
||||
for item in self.notifications:
|
||||
item.icon_ids.clear()
|
||||
|
||||
def configure_status_icons(self, count: int, widths: Iterable[float]) -> None:
|
||||
self.status_pool.configure(count, widths)
|
||||
for item in self.statuses:
|
||||
item.icon_ids.clear()
|
||||
|
||||
def render_containers(self) -> list[Container]:
|
||||
return [*self.clock.lines, *self.notifications, *self.statuses, *self.chips, *self.carriers]
|
||||
|
||||
|
||||
def containers_overlap(first: Container | ClockGroup, second: Container | ClockGroup) -> bool:
|
||||
"""Check horizontal and vertical overlap, expanding a clock into its lines."""
|
||||
first_items = _collision_items(first)
|
||||
second_items = _collision_items(second)
|
||||
for first_item in first_items:
|
||||
for second_item in second_items:
|
||||
if first_item is second_item:
|
||||
continue
|
||||
if rectangles_overlap(first_item.bounds, second_item.bounds):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _collision_items(item: Bounds | Container | ClockGroup) -> list[Container]:
|
||||
if isinstance(item, Bounds):
|
||||
return [_BoundsContainer(item)]
|
||||
return item.lines if isinstance(item, ClockGroup) else [item]
|
||||
|
||||
|
||||
class _BoundsContainer:
|
||||
"""Adapter so the public helpers can accept a raw Bounds instance too."""
|
||||
|
||||
def __init__(self, bounds: Bounds) -> None:
|
||||
self.bounds = bounds
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Tk renderer for the status-bar layout playground."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import font as tkfont
|
||||
from typing import Iterable
|
||||
|
||||
from statusbar_lab import CameraCutout, Container, IconContainer, StatusBarLab, rectangles_overlap
|
||||
|
||||
|
||||
TYPE_COLOURS = {
|
||||
"clock": "#9fd7ff",
|
||||
"notification": "#a7efaa",
|
||||
"status": "#ffd596",
|
||||
"chip": "#e2b0ff",
|
||||
"carrier": "#ffb4c4",
|
||||
}
|
||||
|
||||
|
||||
def render(
|
||||
lab: StatusBarLab,
|
||||
statusbar_width: float,
|
||||
statusbar_height: float,
|
||||
render_scale: float = 1.0,
|
||||
vertical_scale: float = 2.0,
|
||||
margin: int = 40,
|
||||
) -> None:
|
||||
root = tk.Tk()
|
||||
root.title("StatusBarTweak layout playground")
|
||||
width = int(statusbar_width * render_scale + margin * 2)
|
||||
height = int(statusbar_height * vertical_scale + margin * 2)
|
||||
canvas = tk.Canvas(root, width=width, height=height, bg="#242424", highlightthickness=0)
|
||||
canvas.pack()
|
||||
|
||||
def point(x: float, y: float) -> tuple[float, float]:
|
||||
return margin + x * render_scale, margin + (statusbar_height - y) * vertical_scale
|
||||
|
||||
left, bottom = point(0, 0)
|
||||
right, top = point(statusbar_width, statusbar_height)
|
||||
canvas.create_rectangle(left, top, right, bottom, fill="white", outline="#4a4a4a", width=5)
|
||||
|
||||
containers = lab.render_containers()
|
||||
for container in containers:
|
||||
_draw_container(canvas, point, container, render_scale, vertical_scale)
|
||||
|
||||
_draw_overlaps(canvas, point, containers)
|
||||
for camera in lab.cameras:
|
||||
_draw_camera(canvas, point, camera)
|
||||
root.mainloop()
|
||||
|
||||
|
||||
def _draw_container(canvas: tk.Canvas, point, container: Container, render_scale: float, vertical_scale: float) -> None:
|
||||
if isinstance(container, IconContainer):
|
||||
_draw_icons(canvas, point, container)
|
||||
return
|
||||
|
||||
x1, y1 = point(container.left, container.bottom)
|
||||
x2, y2 = point(container.right, container.top)
|
||||
canvas.create_rectangle(x1, y2, x2, y1, fill=TYPE_COLOURS[container.kind], outline="black", width=1)
|
||||
_draw_clipped_text(canvas, container.text, x1 + 3, (y1 + y2) / 2, max(0, x2 - x1 - 6), container.height * vertical_scale)
|
||||
|
||||
|
||||
def _draw_icons(canvas: tk.Canvas, point, container: IconContainer) -> None:
|
||||
colour = TYPE_COLOURS[container.kind]
|
||||
if not container.segments():
|
||||
x, y1 = point(container.left, container.bottom)
|
||||
_, y2 = point(container.left, container.top)
|
||||
canvas.create_line(x, y1, x, y2, fill="black", width=1)
|
||||
return
|
||||
|
||||
cursor = container.left
|
||||
for segment_kind, icon_id, segment_width in container.segments():
|
||||
x1, y1 = point(cursor, container.bottom)
|
||||
x2, y2 = point(cursor + segment_width, container.top)
|
||||
if segment_kind == "gap":
|
||||
cursor += segment_width
|
||||
continue
|
||||
if segment_kind == "dot":
|
||||
canvas.create_rectangle(x1, y2, x2, y1, fill=colour, outline="black", width=1)
|
||||
radius = min(abs(x2 - x1), abs(y1 - y2)) * 0.24
|
||||
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
||||
canvas.create_oval(cx - radius, cy - radius, cx + radius, cy + radius, fill="black", outline="black")
|
||||
else:
|
||||
canvas.create_rectangle(x1, y2, x2, y1, fill=colour, outline="black", width=1)
|
||||
_draw_clipped_text(canvas, str(icon_id), (x1 + x2) / 2, (y1 + y2) / 2, abs(x2 - x1) - 2, abs(y1 - y2), anchor="center")
|
||||
cursor += segment_width
|
||||
|
||||
|
||||
def _draw_camera(canvas: tk.Canvas, point, camera: CameraCutout) -> None:
|
||||
x1, y1 = point(camera.left, camera.bottom)
|
||||
x2, y2 = point(camera.right, camera.top)
|
||||
canvas.create_rectangle(x1, y2, x2, y1, fill="black", outline="black")
|
||||
|
||||
|
||||
def _draw_overlaps(canvas: tk.Canvas, point, containers: Iterable[Container]) -> None:
|
||||
items = list(containers)
|
||||
for index, first in enumerate(items):
|
||||
for second in items[index + 1:]:
|
||||
if first.kind == "clock" and second.kind == "clock":
|
||||
continue
|
||||
if not rectangles_overlap(first.bounds, second.bounds):
|
||||
continue
|
||||
left = max(first.left, second.left)
|
||||
right = min(first.right, second.right)
|
||||
bottom = max(first.bottom, second.bottom)
|
||||
top = min(first.top, second.top)
|
||||
x1, y1 = point(left, bottom)
|
||||
x2, y2 = point(right, top)
|
||||
canvas.create_rectangle(x1, y2, x2, y1, fill="red", outline="red")
|
||||
radius = max(abs(x2 - x1), abs(y1 - y2)) / 2 + 12
|
||||
cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
|
||||
canvas.create_oval(cx - radius, cy - radius, cx + radius, cy + radius, outline="red", width=3)
|
||||
|
||||
|
||||
def _draw_clipped_text(
|
||||
canvas: tk.Canvas,
|
||||
text: str,
|
||||
x: float,
|
||||
y: float,
|
||||
width: float,
|
||||
height: float,
|
||||
anchor: str = "w",
|
||||
) -> None:
|
||||
if width <= 0:
|
||||
return
|
||||
size = max(7, int(height * 0.42))
|
||||
text_font = tkfont.Font(family="TkDefaultFont", size=size)
|
||||
clipped = _clip_text(text_font, text, width)
|
||||
canvas.create_text(x, y, text=clipped, font=text_font, fill="black", anchor=anchor)
|
||||
|
||||
|
||||
def _clip_text(text_font: tkfont.Font, text: str, width: float) -> str:
|
||||
if text_font.measure(text) <= width:
|
||||
return text
|
||||
ellipsis = "..."
|
||||
while text and text_font.measure(text + ellipsis) > width:
|
||||
text = text[:-1]
|
||||
return text + ellipsis if text else ""
|
||||
+1
-1
@@ -1 +1 @@
|
||||
version=0.7
|
||||
version=0.8
|
||||
|
||||
Reference in New Issue
Block a user