5 Commits
19 changed files with 1249 additions and 89 deletions
@@ -1,11 +1,9 @@
package se.ajpanton.statusbartweak.runtime.layout; package se.ajpanton.statusbartweak.runtime.layout;
import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Notification; import android.app.Notification;
import android.app.KeyguardManager; import android.app.KeyguardManager;
import android.content.Context; import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.graphics.Color; import android.graphics.Color;
@@ -13,7 +11,6 @@ import android.graphics.drawable.Drawable;
import android.os.Looper; import android.os.Looper;
import android.os.PowerManager; import android.os.PowerManager;
import android.service.notification.StatusBarNotification; import android.service.notification.StatusBarNotification;
import android.telecom.TelecomManager;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.WindowInsets; 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.NotificationActiveKeyStore;
import se.ajpanton.statusbartweak.runtime.notifications.NotificationUnreadTracker; import se.ajpanton.statusbartweak.runtime.notifications.NotificationUnreadTracker;
import se.ajpanton.statusbartweak.runtime.render.OwnedIconHostManager; 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;
import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController.RenderResult; import se.ajpanton.statusbartweak.runtime.render.UnlockedIconSnapshotRenderController.RenderResult;
import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache; import se.ajpanton.statusbartweak.runtime.settings.RuntimeSettingsCache;
@@ -2812,8 +2810,9 @@ final class StockLayoutCanvasController {
staleViews.add(view); staleViews.add(view);
continue; continue;
} }
if (!isTrackableStatusChipSurface(view) boolean trackable = isTrackableStatusChipSurface(view);
&& !isTrackedHiddenStatusChipSource(root, view)) { boolean retainedHidden = !trackable && isTrackedHiddenStatusChipSource(root, view);
if (!trackable && !retainedHidden) {
forgetTrackedStatusChipState(view); forgetTrackedStatusChipState(view);
staleViews.add(view); staleViews.add(view);
continue; continue;
@@ -3365,7 +3364,7 @@ final class StockLayoutCanvasController {
return visibility != View.VISIBLE return visibility != View.VISIBLE
&& isTrackedStatusChipWriteTarget(view) && isTrackedStatusChipWriteTarget(view)
&& hasVisibleStatusChipContent(view) && hasVisibleStatusChipContent(view)
&& !isCallStatusChip(view); && !StatusChipCallSupport.isCallChip(view);
} }
private boolean isTrackedStatusChipWriteTarget(View view) { private boolean isTrackedStatusChipWriteTarget(View view) {
@@ -3868,6 +3867,9 @@ final class StockLayoutCanvasController {
if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) { if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) {
return false; return false;
} }
if (StatusChipCallSupport.isInactiveCallChip(view)) {
return false;
}
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
@@ -3904,6 +3906,9 @@ final class StockLayoutCanvasController {
if (view.getVisibility() != View.VISIBLE) { if (view.getVisibility() != View.VISIBLE) {
return false; return false;
} }
if (StatusChipCallSupport.isInactiveCallChip(view)) {
return false;
}
String idName = ViewIdNames.idName(view); String idName = ViewIdNames.idName(view);
if (!"ongoing_activity_capsule".equals(idName)) { if (!"ongoing_activity_capsule".equals(idName)) {
return false; return false;
@@ -3932,31 +3937,12 @@ final class StockLayoutCanvasController {
|| !isStatusChipCandidate(view)) { || !isStatusChipCandidate(view)) {
return false; return false;
} }
if (isCallStatusChip(view) if (StatusChipCallSupport.isInactiveCallChip(view)) {
&& view.getVisibility() != View.VISIBLE
&& !isPhoneCallActive(view)) {
return false; return false;
} }
return hasVisibleStatusChipContent(view); 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) { private boolean hasVisibleStatusChipContent(View source) {
if (!(source instanceof ViewGroup group)) { if (!(source instanceof ViewGroup group)) {
return false; return false;
@@ -3986,42 +3972,6 @@ final class StockLayoutCanvasController {
return false; 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) { private boolean isStatusChipCandidate(View view) {
String className = view.getClass().getName().toLowerCase(Locale.ROOT); String className = view.getClass().getName().toLowerCase(Locale.ROOT);
@@ -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) {
}
}
@@ -1,7 +1,7 @@
package se.ajpanton.statusbartweak.runtime.render; package se.ajpanton.statusbartweak.runtime.render;
import android.text.TextUtils;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan; import android.text.style.ForegroundColorSpan;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@@ -19,6 +19,10 @@ final class ClockProxyRenderer {
private final WeakHashMap<ViewGroup, ArrayList<TextView>> rowsByHost = new WeakHashMap<>(); private final WeakHashMap<ViewGroup, ArrayList<TextView>> rowsByHost = new WeakHashMap<>();
void render(ViewGroup host, ClockPlacement placement) { 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()) { if (host == null || placement == null || placement.rows.isEmpty()) {
clear(host); clear(host);
return; return;
@@ -36,13 +40,8 @@ final class ClockProxyRenderer {
views.clear(); views.clear();
hostChanged = true; 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()) { 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); view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
views.add(view); views.add(view);
host.addView(view); host.addView(view);
@@ -50,10 +49,13 @@ final class ClockProxyRenderer {
} }
for (int i = 0; i < placement.rows.size(); i++) { for (int i = 0; i < placement.rows.size(); i++) {
ClockRow row = placement.rows.get(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)) { if (copyTextStyleIfChanged(placement.source, view, placement.textSizePx)) {
hostChanged = true; hostChanged = true;
} }
if (view.setOutline(outline)) {
hostChanged = true;
}
if (applyTextColorOverrideIfNeeded(view, placement.textColorOverride)) { if (applyTextColorOverrideIfNeeded(view, placement.textColorOverride)) {
hostChanged = true; hostChanged = true;
} }
@@ -61,7 +63,7 @@ final class ClockProxyRenderer {
hostChanged = true; hostChanged = true;
} }
if (!sameTextAndColorSpans(view.getText(), row.text)) { if (!sameTextAndColorSpans(view.getText(), row.text)) {
view.setText(row.text); view.setText(row.text, TextView.BufferType.SPANNABLE);
hostChanged = true; hostChanged = true;
} }
if (!Objects.equals(view.getContentDescription(), placement.contentDescription)) { if (!Objects.equals(view.getContentDescription(), placement.contentDescription)) {
@@ -76,21 +78,24 @@ final class ClockProxyRenderer {
view.setAlpha(1f); view.setAlpha(1f);
hostChanged = true; hostChanged = true;
} }
int left = placement.bounds.left + row.x; int inset = view.outlineInsetPx();
int top = placement.bounds.top + row.y; 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); int scrollX = Math.max(0, row.clipLeft);
if (view.getScrollX() != scrollX) { if (view.getScrollX() != scrollX) {
view.setScrollX(scrollX); view.setScrollX(scrollX);
hostChanged = true; hostChanged = true;
} }
if (applyTextLayoutParams(host, view, left, top, row.width, row.height)) { if (applyTextLayoutParams(host, view, left, top, width, height)) {
hostChanged = true; hostChanged = true;
} }
if (view.getLeft() != left if (view.getLeft() != left
|| view.getTop() != top || view.getTop() != top
|| view.getRight() != left + row.width || view.getRight() != left + width
|| view.getBottom() != top + row.height) { || view.getBottom() != top + height) {
view.layout(left, top, left + row.width, top + row.height); view.layout(left, top, left + width, top + height);
hostChanged = true; hostChanged = true;
} }
} }
@@ -211,7 +216,13 @@ final class ClockProxyRenderer {
target.setHorizontallyScrolling(true); target.setHorizontallyScrolling(true);
changed = 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.getPaddingTop() != source.getPaddingTop()
|| target.getPaddingRight() != source.getPaddingRight() || target.getPaddingRight() != source.getPaddingRight()
|| target.getPaddingBottom() != source.getPaddingBottom()) { || target.getPaddingBottom() != source.getPaddingBottom()) {
@@ -234,10 +245,7 @@ final class ClockProxyRenderer {
} }
private static boolean applyTextColorOverrideIfNeeded(TextView view, int color) { private static boolean applyTextColorOverrideIfNeeded(TextView view, int color) {
if (view == null || (color >>> 24) == 0) { if (view == null || (color >>> 24) == 0 || view.getCurrentTextColor() == color) {
return false;
}
if (view.getCurrentTextColor() == color) {
return false; return false;
} }
view.setTextColor(color); 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;
}
}
@@ -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;
}
}
}
@@ -12,6 +12,7 @@ import java.util.WeakHashMap;
import se.ajpanton.statusbartweak.platform.SystemUiCapabilities; import se.ajpanton.statusbartweak.platform.SystemUiCapabilities;
import se.ajpanton.statusbartweak.runtime.features.clock.ClockLayoutTextFactory; 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.layoutsolver.StatusBarLayoutSolver.Bounds;
import se.ajpanton.statusbartweak.runtime.mode.NotificationDisplayStyle; import se.ajpanton.statusbartweak.runtime.mode.NotificationDisplayStyle;
import se.ajpanton.statusbartweak.runtime.mode.SceneKey; import se.ajpanton.statusbartweak.runtime.mode.SceneKey;
@@ -29,6 +30,7 @@ public final class UnlockedIconSnapshotRenderController {
private final SnapshotRenderer notificationRenderer; private final SnapshotRenderer notificationRenderer;
private final SnapshotRenderer chipRenderer; private final SnapshotRenderer chipRenderer;
private final ClockProxyRenderer clockRenderer = new ClockProxyRenderer(); private final ClockProxyRenderer clockRenderer = new ClockProxyRenderer();
private final ClockColorResolver clockColorResolver = new ClockColorResolver();
private final ClockProxyRenderer carrierRenderer = new ClockProxyRenderer(); private final ClockProxyRenderer carrierRenderer = new ClockProxyRenderer();
private final UnlockedStockSourceCollector sourceCollector; private final UnlockedStockSourceCollector sourceCollector;
private final TextVerticalOffsetScaler textOffsetScaler = new TextVerticalOffsetScaler(); private final TextVerticalOffsetScaler textOffsetScaler = new TextVerticalOffsetScaler();
@@ -154,7 +156,7 @@ public final class UnlockedIconSnapshotRenderController {
LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock); LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock);
lastLayoutPlanByRoot.put(root, updatedPlan); lastLayoutPlanByRoot.put(root, updatedPlan);
lastClockSourceGeometryByRoot.put(root, currentClockSourceGeometry); lastClockSourceGeometryByRoot.put(root, currentClockSourceGeometry);
clockRenderer.render(clockHost, updatedClock); renderClock(clockHost, updatedClock, settings);
return new RenderResult(false, false, false); return new RenderResult(false, false, false);
} }
} }
@@ -181,7 +183,7 @@ public final class UnlockedIconSnapshotRenderController {
updatedClock)) { updatedClock)) {
LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock); LayoutPlan updatedPlan = previousPlan.withClockPlacement(updatedClock);
lastLayoutPlanByRoot.put(root, updatedPlan); lastLayoutPlanByRoot.put(root, updatedPlan);
clockRenderer.render(clockHost, updatedClock); renderClock(clockHost, updatedClock, settings);
return new RenderResult(false, false, false); return new RenderResult(false, false, false);
} }
} }
@@ -239,7 +241,7 @@ public final class UnlockedIconSnapshotRenderController {
layoutPlan = withCurrentCarrierAppearance(layoutPlan); layoutPlan = withCurrentCarrierAppearance(layoutPlan);
lastLayoutPlanByRoot.put(root, layoutPlan); lastLayoutPlanByRoot.put(root, layoutPlan);
if (clockEnabledForScene(settings, scene) && layoutPlan.clockPlacement != null) { if (clockEnabledForScene(settings, scene) && layoutPlan.clockPlacement != null) {
clockRenderer.render(clockHost, layoutPlan.clockPlacement); renderClock(clockHost, layoutPlan.clockPlacement, settings);
} else { } else {
clockRenderer.clear(clockHost); clockRenderer.clear(clockHost);
} }
@@ -315,7 +317,7 @@ public final class UnlockedIconSnapshotRenderController {
layoutPlan = updatedPlan; layoutPlan = updatedPlan;
} }
if (settings.clockEnabledUnlocked && layoutPlan.clockPlacement != null) { 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); LayoutPlan updatedPlan = layoutPlan.withClockPlacement(updatedClock);
lastLayoutPlanByRoot.put(root, updatedPlan); lastLayoutPlanByRoot.put(root, updatedPlan);
lastClockAppearanceByRoot.put(root, clockAppearanceSignature(updatedClock, referenceColor, settings)); lastClockAppearanceByRoot.put(root, clockAppearanceSignature(updatedClock, referenceColor, settings));
clockRenderer.render(clockHost, updatedClock); renderClock(clockHost, updatedClock, settings);
return true; return true;
} }
@@ -654,6 +656,51 @@ public final class UnlockedIconSnapshotRenderController {
return statusBarTintTargetEnabled ? StatusBarTintTarget.color() : android.graphics.Color.TRANSPARENT; 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) { private boolean hasAlpha(int color) {
return (color >>> 24) != 0; return (color >>> 24) != 0;
} }
@@ -689,7 +736,10 @@ public final class UnlockedIconSnapshotRenderController {
settings.clockShowSeconds, settings.clockShowSeconds,
settings.clockShowDatePrefix, settings.clockShowDatePrefix,
settings.clockCustomFormatEnabled, settings.clockCustomFormatEnabled,
settings.clockCustomFormat); settings.clockCustomFormat,
settings.clockOutlineEnabled,
settings.clockOutlineThicknessDp,
settings.clockOutlineColor);
} }
public void clearAll() { public void clearAll() {
@@ -1220,6 +1220,7 @@ final class UnlockedStockSourceCollector {
|| !view.isAttachedToWindow() || !view.isAttachedToWindow()
|| !ViewGeometry.isDescendantOf(view, root) || !ViewGeometry.isDescendantOf(view, root)
|| ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false) || ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)
|| StatusChipCallSupport.isInactiveCallChip(view)
|| !isStatusChipCandidate(view) || !isStatusChipCandidate(view)
|| !hasRenderableStatusChipContent(view)) { || !hasRenderableStatusChipContent(view)) {
return false; return false;
@@ -1245,6 +1246,9 @@ final class UnlockedStockSourceCollector {
if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) { if (ReflectionSupport.getBooleanField(view, "sbtStatusChipForcedHidden", false)) {
return false; return false;
} }
if (StatusChipCallSupport.isInactiveCallChip(view)) {
return false;
}
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
if (width <= 0 || height <= 0 || root == null || width >= root.getWidth() / 2) { 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)) { if (!allowHidden && view.getAlpha() <= 0f && !hasRenderableStatusChipContent(view)) {
return false; return false;
} }
if (StatusChipCallSupport.isInactiveCallChip(view)) {
return false;
}
int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth(); int width = view.getWidth() > 0 ? view.getWidth() : view.getMeasuredWidth();
int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight(); int height = view.getHeight() > 0 ? view.getHeight() : view.getMeasuredHeight();
if (width <= 0 || height <= 0 || width >= root.getWidth() / 2) { 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_SHOW_DATE_PREFIX_DEFAULT = false;
public static final boolean CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT = false; public static final boolean CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT = false;
public static final String CLOCK_CUSTOM_FORMAT_DEFAULT = ""; 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 boolean DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT = false;
public static final String DRAWER_CLOCK_CUSTOM_FORMAT_DEFAULT = ""; public static final String DRAWER_CLOCK_CUSTOM_FORMAT_DEFAULT = "";
public static final boolean DRAWER_DATE_CUSTOM_FORMAT_ENABLED_DEFAULT = false; 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_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_ENABLED = "clock_custom_format_enabled";
public static final String KEY_CLOCK_CUSTOM_FORMAT = "clock_custom_format"; 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 = public static final String KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED =
"drawer_clock_custom_format_enabled"; "drawer_clock_custom_format_enabled";
public static final String KEY_DRAWER_CLOCK_CUSTOM_FORMAT = "drawer_clock_custom_format"; 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 clockShowDatePrefix;
public final boolean clockCustomFormatEnabled; public final boolean clockCustomFormatEnabled;
public final String clockCustomFormat; public final String clockCustomFormat;
public final boolean clockOutlineEnabled;
public final int clockOutlineThicknessDp;
public final String clockOutlineColor;
public final boolean drawerClockCustomFormatEnabled; public final boolean drawerClockCustomFormatEnabled;
public final String drawerClockCustomFormat; public final String drawerClockCustomFormat;
public final boolean drawerDateCustomFormatEnabled; public final boolean drawerDateCustomFormatEnabled;
@@ -589,6 +595,9 @@ public final class SbtSettings {
boolean clockShowDatePrefix, boolean clockShowDatePrefix,
boolean clockCustomFormatEnabled, boolean clockCustomFormatEnabled,
String clockCustomFormat, String clockCustomFormat,
boolean clockOutlineEnabled,
int clockOutlineThicknessDp,
String clockOutlineColor,
boolean drawerClockCustomFormatEnabled, boolean drawerClockCustomFormatEnabled,
String drawerClockCustomFormat, String drawerClockCustomFormat,
boolean drawerDateCustomFormatEnabled, boolean drawerDateCustomFormatEnabled,
@@ -762,6 +771,13 @@ public final class SbtSettings {
this.clockCustomFormat = clockCustomFormat != null this.clockCustomFormat = clockCustomFormat != null
? clockCustomFormat ? clockCustomFormat
: SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT; : 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.drawerClockCustomFormatEnabled = drawerClockCustomFormatEnabled;
this.drawerClockCustomFormat = drawerClockCustomFormat != null this.drawerClockCustomFormat = drawerClockCustomFormat != null
? drawerClockCustomFormat ? 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_SHOW_DATE_PREFIX, SbtDefaults.CLOCK_SHOW_DATE_PREFIX_DEFAULT),
prefs.getBoolean(KEY_CLOCK_CUSTOM_FORMAT_ENABLED, SbtDefaults.CLOCK_CUSTOM_FORMAT_ENABLED_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.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, prefs.getBoolean(KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
SbtDefaults.DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT), SbtDefaults.DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT),
prefs.getString(KEY_DRAWER_CLOCK_CUSTOM_FORMAT, 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)); prefs.getBoolean(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT_ENABLED, SbtDefaults.CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT));
out.putString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT, out.putString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT,
prefs.getString(SbtSettings.KEY_CLOCK_CUSTOM_FORMAT, SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT)); 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, out.putBoolean(SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
prefs.getBoolean( prefs.getBoolean(
SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED, 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.button.MaterialButton;
import com.google.android.material.card.MaterialCardView; import com.google.android.material.card.MaterialCardView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.slider.Slider;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
@@ -93,6 +94,11 @@ public final class ClockFragment extends Fragment {
EditText customFormatInput = root.findViewById(R.id.clock_custom_format_input); EditText customFormatInput = root.findViewById(R.id.clock_custom_format_input);
ClockPatternPreviewView customFormatPreview = ClockPatternPreviewView customFormatPreview =
root.findViewById(R.id.clock_custom_format_preview); 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 = MaterialCardView drawerClockCustomFormatCard =
root.findViewById(R.id.drawer_clock_custom_format_card); root.findViewById(R.id.drawer_clock_custom_format_card);
CheckBox drawerClockCustomFormatEnabled = CheckBox drawerClockCustomFormatEnabled =
@@ -129,6 +135,12 @@ public final class ClockFragment extends Fragment {
String customFormatValue = prefs.getString( String customFormatValue = prefs.getString(
SbtSettings.KEY_CLOCK_CUSTOM_FORMAT, SbtSettings.KEY_CLOCK_CUSTOM_FORMAT,
SbtDefaults.CLOCK_CUSTOM_FORMAT_DEFAULT); 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( boolean drawerClockCustomFormatEnabledValue = prefs.getBoolean(
SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED, SbtSettings.KEY_DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED,
SbtDefaults.DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT); SbtDefaults.DRAWER_CLOCK_CUSTOM_FORMAT_ENABLED_DEFAULT);
@@ -160,6 +172,15 @@ public final class ClockFragment extends Fragment {
customFormatValue, customFormatValue,
18f, 18f,
true); true);
bindClockOutline(
prefs,
clockOutlineEnabled,
clockOutlineContainer,
clockOutlineThickness,
clockOutlineThicknessLabel,
clockOutlineThicknessDp,
clockOutlineColor,
clockOutlineColorValue);
bindCustomFormat( bindCustomFormat(
prefs, prefs,
drawerClockCustomFormatCard, drawerClockCustomFormatCard,
@@ -218,6 +239,69 @@ public final class ClockFragment extends Fragment {
return root; 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) { private void updateCustomFormatVisibility(View customFormatContainer, boolean enabled) {
if (customFormatContainer == null) { if (customFormatContainer == null) {
return; return;
@@ -79,6 +79,71 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </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 <com.google.android.material.card.MaterialCardView
android:id="@+id/drawer_clock_custom_format_card" android:id="@+id/drawer_clock_custom_format_card"
android:layout_width="match_parent" android:layout_width="match_parent"
+4
View File
@@ -212,6 +212,10 @@
<string name="clock_custom_format_drawer_date_title">Drawer date</string> <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_tools_title">Tools and instructions</string>
<string name="clock_custom_format_enabled">Enable custom date/time pattern</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_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="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: &lt;i&gt;, &lt;u&gt;, &lt;small&gt;, &lt;big&gt;, &lt;font color=\'...\'&gt;, &lt;font face=\'...\'&gt;\n\u2022 &lt;b&gt; is not supported\n\u2022 Custom font tags: &lt;sans&gt;, &lt;serif&gt;, &lt;mono&gt;, &lt;condensed&gt;\n\u2022 {/font} closes any open &lt;font ...&gt; tag\n\u2022 Opening a new colour or font face automatically closes the previous one</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: &lt;i&gt;, &lt;u&gt;, &lt;small&gt;, &lt;big&gt;, &lt;font color=\'...\'&gt;, &lt;font face=\'...\'&gt;\n\u2022 &lt;b&gt; is not supported\n\u2022 Custom font tags: &lt;sans&gt;, &lt;serif&gt;, &lt;mono&gt;, &lt;condensed&gt;\n\u2022 {/font} closes any open &lt;font ...&gt; tag\n\u2022 Opening a new colour or font face automatically closes the previous one</string>
+186
View File
@@ -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)
+428
View File
@@ -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
+139
View File
@@ -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
View File
@@ -1 +1 @@
version=0.7 version=0.8