From a0595c9b624148d34f6af4aec32ba9dfbc12cb57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:00:18 +0000 Subject: [PATCH 1/4] Tighten OpenDyslexic spacing so text fits its containers OpenDyslexic ships very wide glyph advances and tall line metrics "by design", which in a UI whose containers are sized for normal fonts means text no longer fits. Claw the spacing back to something usable with a negative letter spacing (-0.05em) and a sub-1 line-spacing multiplier (0.8), applied only when OpenDyslexic is selected. Introduce a FontStyle holder (typeface + letterSpacing + lineSpacingMultiplier) that carries these corrections with the typeface and applies them wherever the font is forced onto TextViews - both the inflater factory and the dialog decor-view sweep. Other fonts get neutral defaults, so they are unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GosWNcfE3dfMAmHCn4WNoy --- .../preferences/FontInflaterFactory.kt | 12 ++-- .../preferences/FontPreference.kt | 67 ++++++++++++++++--- .../robinj/distrohopper/FontPreferenceTest.kt | 29 +++++++- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/be/robinj/distrohopper/preferences/FontInflaterFactory.kt b/app/src/main/java/be/robinj/distrohopper/preferences/FontInflaterFactory.kt index 434e20e8..5324e17a 100644 --- a/app/src/main/java/be/robinj/distrohopper/preferences/FontInflaterFactory.kt +++ b/app/src/main/java/be/robinj/distrohopper/preferences/FontInflaterFactory.kt @@ -1,7 +1,6 @@ package be.robinj.distrohopper.preferences import android.content.Context -import android.graphics.Typeface import android.util.AttributeSet import android.view.LayoutInflater import android.view.View @@ -9,8 +8,9 @@ import android.widget.TextView import androidx.appcompat.app.AppCompatDelegate /** - * A [LayoutInflater.Factory2] that forces a [Typeface] onto every [TextView] as - * it is inflated, so the user's chosen font reaches the whole app. + * A [LayoutInflater.Factory2] that forces a [FontStyle] onto every [TextView] as + * it is inflated, so the user's chosen font (and its spacing corrections) reach + * the whole app. * * Setting a font at the theme level does not cascade to TextViews (the * framework's default text appearances pin fontFamily to sans-serif, which @@ -26,7 +26,7 @@ import androidx.appcompat.app.AppCompatDelegate */ class FontInflaterFactory( private val delegate: AppCompatDelegate, - private val typeface: Typeface, + private val fontStyle: FontStyle, ) : LayoutInflater.Factory2 { override fun onCreateView( @@ -37,9 +37,7 @@ class FontInflaterFactory( ): View? { val view = this.delegate.createView(parent, name, context, attrs) if (view is TextView) { - // Keep the view's own style (bold/italic) while swapping the family. - val style = view.typeface?.style ?: Typeface.NORMAL - view.setTypeface(this.typeface, style) + this.fontStyle.applyTo(view) } return view diff --git a/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt b/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt index 061fcc9d..e42411cf 100644 --- a/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt +++ b/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt @@ -27,6 +27,17 @@ object FontPreference { const val SYSTEM = "system" + /** + * OpenDyslexic ships very wide glyph advances and tall line metrics "by + * design". In a UI whose containers are sized for normal fonts that means + * text no longer fits, so we claw the spacing back to something usable with a + * negative [FontStyle.letterSpacing] (em units) and a sub-1 + * [FontStyle.lineSpacingMultiplier]. These only tighten OpenDyslexic; every + * other font keeps the neutral defaults below, which are no-ops. + */ + private const val OPENDYSLEXIC_LETTER_SPACING = -0.05f + private const val OPENDYSLEXIC_LINE_SPACING_MULTIPLIER = 0.8f + /** Bundled font resource for [value], or null for System / unknown values. */ @FontRes fun fontResFor(value: String?): Int? = when (value) { @@ -42,6 +53,26 @@ object FontPreference { return ResourcesCompat.getFont(context, fontRes) } + /** + * The selected font together with any per-font metric tweaks, or null when + * the system font should be used. This is what actually gets forced onto + * TextViews (see [FontStyle.applyTo]); prefer it over [typeface] so the + * spacing corrections travel with the typeface. + */ + fun fontStyle(context: Context): FontStyle? { + val value = this.current(context) + val typeface = this.typeface(context) ?: return null + return if (value == "opendyslexic") { + FontStyle( + typeface, + OPENDYSLEXIC_LETTER_SPACING, + OPENDYSLEXIC_LINE_SPACING_MULTIPLIER, + ) + } else { + FontStyle(typeface) + } + } + private fun current(context: Context): String = Preferences.getSharedPreferences(context) .getString(Preference.FONT.getName(), SYSTEM) ?: SYSTEM @@ -54,7 +85,7 @@ object FontPreference { */ fun applyTo(activity: Activity) { if (activity !is AppCompatActivity) return - val typeface = this.typeface(activity) ?: return + val fontStyle = this.fontStyle(activity) ?: return // We run before AppCompat installs its own factory, so the inflater // should be untouched; guard like AppCompat does to never clobber an @@ -64,7 +95,7 @@ object FontPreference { LayoutInflaterCompat.setFactory2( inflater, - FontInflaterFactory(activity.delegate, typeface), + FontInflaterFactory(activity.delegate, fontStyle), ) } @@ -77,19 +108,39 @@ object FontPreference { * Attach via [Dialog.setOnShowListener] so the views exist when this runs. */ fun applyTo(dialog: Dialog) { - val typeface = this.typeface(dialog.context) ?: return + val fontStyle = this.fontStyle(dialog.context) ?: return val root = dialog.window?.decorView ?: return - this.applyTypeface(root, typeface) + this.applyFontStyle(root, fontStyle) } - private fun applyTypeface(view: View, typeface: Typeface) { + private fun applyFontStyle(view: View, fontStyle: FontStyle) { when (view) { is ViewGroup -> for (i in 0 until view.childCount) { - this.applyTypeface(view.getChildAt(i), typeface) + this.applyFontStyle(view.getChildAt(i), fontStyle) } - // Keep each view's own style (bold/italic) while swapping the family. - is TextView -> view.setTypeface(typeface, view.typeface?.style ?: Typeface.NORMAL) + is TextView -> fontStyle.applyTo(view) } } } + +/** + * A chosen [typeface] plus the per-font metric corrections that must travel with + * it: [letterSpacing] (em units, negative to tighten) and + * [lineSpacingMultiplier] (< 1 to pull lines closer). The defaults are neutral, + * so fonts that need no correction get an identity transform. + */ +class FontStyle( + val typeface: Typeface, + val letterSpacing: Float = 0f, + val lineSpacingMultiplier: Float = 1f, +) { + + /** Forces this font (and its spacing) onto [view], keeping its bold/italic. */ + fun applyTo(view: TextView) { + // Keep each view's own style (bold/italic) while swapping the family. + view.setTypeface(this.typeface, view.typeface?.style ?: Typeface.NORMAL) + view.letterSpacing = this.letterSpacing + view.setLineSpacing(0f, this.lineSpacingMultiplier) + } +} diff --git a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt index 77a73c27..d516b956 100644 --- a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt @@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ApplicationProvider import be.robinj.distrohopper.preferences.FontInflaterFactory import be.robinj.distrohopper.preferences.FontPreference +import be.robinj.distrohopper.preferences.FontStyle import be.robinj.distrohopper.preferences.Preference import be.robinj.distrohopper.preferences.Preferences import org.junit.Assert.assertEquals @@ -60,7 +61,7 @@ class FontPreferenceTest { this.setFont("system") val activity = Robolectric.buildActivity(FontTestActivity::class.java).create().get() - val factory = FontInflaterFactory(activity.delegate, Typeface.MONOSPACE) + val factory = FontInflaterFactory(activity.delegate, FontStyle(Typeface.MONOSPACE)) val attrs = Robolectric.buildAttributeSet().build() val view = factory.onCreateView(null, "TextView", activity, attrs) @@ -68,6 +69,32 @@ class FontPreferenceTest { assertEquals(Typeface.MONOSPACE, (view as TextView).typeface) } + /** OpenDyslexic's baked-in spacing is clawed back: negative letter spacing + * and a sub-1 line-spacing multiplier so text fits its containers. */ + @Test fun openDyslexicTightensSpacing() { + this.setFont("opendyslexic") + val style = FontPreference.fontStyle(this.context) + + assertNotNull(style) + assertTrue("letter spacing should be negative", style!!.letterSpacing < 0f) + assertTrue("line spacing multiplier should be < 1", style.lineSpacingMultiplier < 1f) + + val tv = TextView(this.context) + style.applyTo(tv) + assertEquals(style.letterSpacing, tv.letterSpacing, 0f) + assertEquals(style.lineSpacingMultiplier, tv.lineSpacingMultiplier, 0f) + } + + /** Other bundled fonts get neutral (identity) spacing, so they're unchanged. */ + @Test fun otherFontsKeepNeutralSpacing() { + this.setFont("ubuntu") + val style = FontPreference.fontStyle(this.context) + + assertNotNull(style) + assertEquals(0f, style!!.letterSpacing, 0f) + assertEquals(1f, style.lineSpacingMultiplier, 0f) + } + /** applyTo is a harmless no-op when the system font is selected. */ @Test fun applyToIsNoOpForSystem() { this.setFont("system") From 930d4b4209b656c8b8c1e2f9a12415846be87a1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:10:01 +0000 Subject: [PATCH 2/4] Apply font spacing corrections relative to each view's own spacing The previous FontStyle wrote absolute letterSpacing/lineSpacing onto every TextView, which for neutral fonts (Ubuntu/Oxygen/System) wiped explicit tracking set in styles and layouts (e.g. ModernDialogButton's 0.01, the widget picker header's 0.04) instead of leaving it alone. Make the correction relative: letterSpacingDelta is added to the view's existing letter spacing and lineSpacingFactor multiplies its existing line multiplier. The neutral defaults (delta 0, factor 1) are now an exact identity and are skipped entirely, so non-OpenDyslexic fonts keep each view's spacing untouched; OpenDyslexic tightens relative to it, preserving intentional tracking differences. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GosWNcfE3dfMAmHCn4WNoy --- .../preferences/FontPreference.kt | 46 +++++++++++++------ .../robinj/distrohopper/FontPreferenceTest.kt | 31 ++++++++----- 2 files changed, 52 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt b/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt index e42411cf..49380235 100644 --- a/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt +++ b/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt @@ -31,12 +31,14 @@ object FontPreference { * OpenDyslexic ships very wide glyph advances and tall line metrics "by * design". In a UI whose containers are sized for normal fonts that means * text no longer fits, so we claw the spacing back to something usable with a - * negative [FontStyle.letterSpacing] (em units) and a sub-1 - * [FontStyle.lineSpacingMultiplier]. These only tighten OpenDyslexic; every - * other font keeps the neutral defaults below, which are no-ops. + * negative [FontStyle.letterSpacingDelta] (em units) and a sub-1 + * [FontStyle.lineSpacingFactor]. Both are applied *relative* to each view's + * own spacing (see [FontStyle.applyTo]), so intentional tracking set in + * layouts/styles is tightened, not discarded. These only touch OpenDyslexic; + * every other font keeps the neutral defaults below, which are exact no-ops. */ - private const val OPENDYSLEXIC_LETTER_SPACING = -0.05f - private const val OPENDYSLEXIC_LINE_SPACING_MULTIPLIER = 0.8f + private const val OPENDYSLEXIC_LETTER_SPACING_DELTA = -0.05f + private const val OPENDYSLEXIC_LINE_SPACING_FACTOR = 0.8f /** Bundled font resource for [value], or null for System / unknown values. */ @FontRes @@ -65,8 +67,8 @@ object FontPreference { return if (value == "opendyslexic") { FontStyle( typeface, - OPENDYSLEXIC_LETTER_SPACING, - OPENDYSLEXIC_LINE_SPACING_MULTIPLIER, + OPENDYSLEXIC_LETTER_SPACING_DELTA, + OPENDYSLEXIC_LINE_SPACING_FACTOR, ) } else { FontStyle(typeface) @@ -126,21 +128,37 @@ object FontPreference { /** * A chosen [typeface] plus the per-font metric corrections that must travel with - * it: [letterSpacing] (em units, negative to tighten) and - * [lineSpacingMultiplier] (< 1 to pull lines closer). The defaults are neutral, - * so fonts that need no correction get an identity transform. + * it, expressed *relative* to whatever spacing each view already has: + * [letterSpacingDelta] (em units, added — negative to tighten) and + * [lineSpacingFactor] (multiplies the view's line-spacing multiplier — < 1 to + * pull lines closer). Applying relatively means a view's intentional tracking + * (e.g. a style's `letterSpacing="0.04"`) is preserved and shifted, not wiped. + * + * The defaults (delta 0, factor 1) are an exact identity: for fonts that need no + * correction, [applyTo] touches only the typeface and leaves spacing untouched. */ class FontStyle( val typeface: Typeface, - val letterSpacing: Float = 0f, - val lineSpacingMultiplier: Float = 1f, + val letterSpacingDelta: Float = 0f, + val lineSpacingFactor: Float = 1f, ) { /** Forces this font (and its spacing) onto [view], keeping its bold/italic. */ fun applyTo(view: TextView) { // Keep each view's own style (bold/italic) while swapping the family. view.setTypeface(this.typeface, view.typeface?.style ?: Typeface.NORMAL) - view.letterSpacing = this.letterSpacing - view.setLineSpacing(0f, this.lineSpacingMultiplier) + + // Adjust relative to the view's own spacing so explicit tracking/leading + // set in layouts and styles survives; a zero delta / unit factor is a + // no-op, so neutral fonts leave these properties exactly as they were. + if (this.letterSpacingDelta != 0f) { + view.letterSpacing = view.letterSpacing + this.letterSpacingDelta + } + if (this.lineSpacingFactor != 1f) { + view.setLineSpacing( + view.lineSpacingExtra, + view.lineSpacingMultiplier * this.lineSpacingFactor, + ) + } } } diff --git a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt index d516b956..b65323ec 100644 --- a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt @@ -69,30 +69,39 @@ class FontPreferenceTest { assertEquals(Typeface.MONOSPACE, (view as TextView).typeface) } - /** OpenDyslexic's baked-in spacing is clawed back: negative letter spacing - * and a sub-1 line-spacing multiplier so text fits its containers. */ + /** OpenDyslexic's baked-in spacing is clawed back: a negative letter-spacing + * delta and a sub-1 line-spacing factor so text fits its containers. */ @Test fun openDyslexicTightensSpacing() { this.setFont("opendyslexic") val style = FontPreference.fontStyle(this.context) assertNotNull(style) - assertTrue("letter spacing should be negative", style!!.letterSpacing < 0f) - assertTrue("line spacing multiplier should be < 1", style.lineSpacingMultiplier < 1f) + assertTrue("letter spacing delta should be negative", style!!.letterSpacingDelta < 0f) + assertTrue("line spacing factor should be < 1", style.lineSpacingFactor < 1f) - val tv = TextView(this.context) + val tv = TextView(this.context).apply { + this.letterSpacing = 0.04f // explicit tracking, e.g. from a style + this.setLineSpacing(0f, 1f) + } style.applyTo(tv) - assertEquals(style.letterSpacing, tv.letterSpacing, 0f) - assertEquals(style.lineSpacingMultiplier, tv.lineSpacingMultiplier, 0f) + // Correction is relative: existing 0.04 tracking is tightened, not wiped. + assertEquals(0.04f + style.letterSpacingDelta, tv.letterSpacing, 1e-6f) + assertEquals(style.lineSpacingFactor, tv.lineSpacingMultiplier, 1e-6f) } - /** Other bundled fonts get neutral (identity) spacing, so they're unchanged. */ - @Test fun otherFontsKeepNeutralSpacing() { + /** Other bundled fonts get an identity transform: existing letter spacing set + * by a style/layout is preserved, not clobbered to 0. */ + @Test fun otherFontsPreserveExistingSpacing() { this.setFont("ubuntu") val style = FontPreference.fontStyle(this.context) assertNotNull(style) - assertEquals(0f, style!!.letterSpacing, 0f) - assertEquals(1f, style.lineSpacingMultiplier, 0f) + assertEquals(0f, style!!.letterSpacingDelta, 0f) + assertEquals(1f, style.lineSpacingFactor, 0f) + + val tv = TextView(this.context).apply { this.letterSpacing = 0.04f } + style.applyTo(tv) + assertEquals("neutral font must not touch letter spacing", 0.04f, tv.letterSpacing, 0f) } /** applyTo is a harmless no-op when the system font is selected. */ From eabe3602d65f71c93a748d7385b84f4ed7af8b7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:55:12 +0000 Subject: [PATCH 3/4] Make font spacing corrections idempotent The relative corrections introduced in the previous commit compounded when the same TextView was reached more than once, and the design deliberately applies the font through two overlapping paths: the inflater factory at inflate time and the dialog decor-view sweep on show. WidgetPickerDialog hits exactly that case - its adapter inflates rows with LayoutInflater.from(activity), which carries the font factory, and those same rows sit under the decor view that applyTo(dialog) later sweeps. Rows present during the sweep would end up at 0.04 - 0.05 - 0.05 letter spacing and a 0.64 line multiplier, while rows created afterwards got the single correction, so identical rows rendered differently. Re-showing a dialog compounded it further on every show. Anchor the correction to each view's designed spacing instead: capture letterSpacing, lineSpacingExtra and lineSpacingMultiplier in a tag the first time a view is seen, and always recompute from that baseline rather than from the current value. Applying a style any number of times, through any combination of paths, now lands on the same result. Add regression tests for repeated application, for the inflate-then-sweep path using the real widget_picker_header layout, for line-spacing extra being carried through, and for bold surviving the family swap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GosWNcfE3dfMAmHCn4WNoy --- .../preferences/FontPreference.kt | 56 +++++++++---- app/src/main/res/values/ids.xml | 10 +++ .../robinj/distrohopper/FontPreferenceTest.kt | 83 ++++++++++++++++++- 3 files changed, 132 insertions(+), 17 deletions(-) create mode 100644 app/src/main/res/values/ids.xml diff --git a/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt b/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt index 49380235..2ee95f03 100644 --- a/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt +++ b/app/src/main/java/be/robinj/distrohopper/preferences/FontPreference.kt @@ -128,11 +128,17 @@ object FontPreference { /** * A chosen [typeface] plus the per-font metric corrections that must travel with - * it, expressed *relative* to whatever spacing each view already has: + * it, expressed *relative* to each view's designed spacing: * [letterSpacingDelta] (em units, added — negative to tighten) and - * [lineSpacingFactor] (multiplies the view's line-spacing multiplier — < 1 to - * pull lines closer). Applying relatively means a view's intentional tracking - * (e.g. a style's `letterSpacing="0.04"`) is preserved and shifted, not wiped. + * [lineSpacingFactor] (multiplies the line-spacing multiplier — < 1 to pull + * lines closer). Applying relatively means a view's intentional tracking (e.g. a + * style's `letterSpacing="0.04"`) is preserved and shifted, not wiped. + * + * "Designed spacing" is the view's spacing the first time it is seen, captured + * and remembered in a tag. Corrections are always recomputed from that baseline, + * never from the current value, so [applyTo] is idempotent: a view reached by + * both the [FontInflaterFactory] and the dialog decor sweep — or swept again on + * every [Dialog] show — lands on the same spacing instead of compounding. * * The defaults (delta 0, factor 1) are an exact identity: for fonts that need no * correction, [applyTo] touches only the typeface and leaves spacing untouched. @@ -143,22 +149,40 @@ class FontStyle( val lineSpacingFactor: Float = 1f, ) { + /** True when this style leaves text spacing exactly as the view declared it. */ + private val spacingIsNeutral: Boolean + get() = this.letterSpacingDelta == 0f && this.lineSpacingFactor == 1f + /** Forces this font (and its spacing) onto [view], keeping its bold/italic. */ fun applyTo(view: TextView) { // Keep each view's own style (bold/italic) while swapping the family. view.setTypeface(this.typeface, view.typeface?.style ?: Typeface.NORMAL) - // Adjust relative to the view's own spacing so explicit tracking/leading - // set in layouts and styles survives; a zero delta / unit factor is a - // no-op, so neutral fonts leave these properties exactly as they were. - if (this.letterSpacingDelta != 0f) { - view.letterSpacing = view.letterSpacing + this.letterSpacingDelta - } - if (this.lineSpacingFactor != 1f) { - view.setLineSpacing( - view.lineSpacingExtra, - view.lineSpacingMultiplier * this.lineSpacingFactor, - ) - } + // Never touch spacing when there is no correction to make, so fonts that + // need none leave every view exactly as its layout/style declared it. + if (this.spacingIsNeutral) return + + val baseline = this.baselineOf(view) + view.letterSpacing = baseline.letterSpacing + this.letterSpacingDelta + view.setLineSpacing( + baseline.lineSpacingExtra, + baseline.lineSpacingMultiplier * this.lineSpacingFactor, + ) } + + /** The view's designed spacing, captured on first sight and reused after. */ + private fun baselineOf(view: TextView): SpacingBaseline = + view.getTag(R.id.font_spacing_baseline) as? SpacingBaseline + ?: SpacingBaseline( + view.letterSpacing, + view.lineSpacingExtra, + view.lineSpacingMultiplier, + ).also { view.setTag(R.id.font_spacing_baseline, it) } + + /** A view's text spacing as its layout/style declared it, before correction. */ + private class SpacingBaseline( + val letterSpacing: Float, + val lineSpacingExtra: Float, + val lineSpacingMultiplier: Float, + ) } diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 00000000..b37f4c2d --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt index b65323ec..7a07dd1b 100644 --- a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt @@ -4,9 +4,11 @@ import android.app.Dialog import android.content.Context import android.graphics.Typeface import android.os.Bundle +import android.view.LayoutInflater import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.LayoutInflaterCompat import androidx.test.core.app.ApplicationProvider import be.robinj.distrohopper.preferences.FontInflaterFactory import be.robinj.distrohopper.preferences.FontPreference @@ -99,9 +101,88 @@ class FontPreferenceTest { assertEquals(0f, style!!.letterSpacingDelta, 0f) assertEquals(1f, style.lineSpacingFactor, 0f) - val tv = TextView(this.context).apply { this.letterSpacing = 0.04f } + val tv = TextView(this.context).apply { + this.letterSpacing = 0.04f + this.setLineSpacing(8f, 1.1f) + } style.applyTo(tv) assertEquals("neutral font must not touch letter spacing", 0.04f, tv.letterSpacing, 0f) + assertEquals("neutral font must not touch line spacing", 8f, tv.lineSpacingExtra, 0f) + assertEquals("neutral font must not touch line spacing", 1.1f, tv.lineSpacingMultiplier, 0f) + } + + /** A view can be reached by both the inflater factory and the dialog decor + * sweep (and swept again on every show), so corrections must be computed + * from the view's designed spacing and never compound. */ + @Test fun spacingCorrectionIsIdempotent() { + this.setFont("opendyslexic") + val style = FontPreference.fontStyle(this.context)!! + + val tv = TextView(this.context).apply { + this.letterSpacing = 0.04f + this.setLineSpacing(0f, 1f) + } + + style.applyTo(tv) + val letterSpacingAfterOnce = tv.letterSpacing + val lineSpacingAfterOnce = tv.lineSpacingMultiplier + + style.applyTo(tv) + style.applyTo(tv) + + assertEquals(letterSpacingAfterOnce, tv.letterSpacing, 1e-6f) + assertEquals(lineSpacingAfterOnce, tv.lineSpacingMultiplier, 1e-6f) + // Explicitly: the third application must not have drifted to 0.04 - 0.15. + assertEquals(0.04f + style.letterSpacingDelta, tv.letterSpacing, 1e-6f) + } + + /** The real compounding path: WidgetPickerDialog's adapter inflates rows with + * an inflater that carries the font factory, and those same rows are then + * caught by the dialog decor sweep. They must be corrected exactly once. */ + @Test fun factoryThenDialogSweepDoesNotCompound() { + this.setFont("opendyslexic") + val style = FontPreference.fontStyle(this.context)!! + val activity = Robolectric.buildActivity(FontTestActivity::class.java).create().get() + + val inflater = LayoutInflater.from(activity).cloneInContext(activity) + LayoutInflaterCompat.setFactory2(inflater, FontInflaterFactory(activity.delegate, style)) + val row = inflater.inflate(R.layout.widget_picker_header, null) + val tvName = row.findViewById(R.id.tvName) + + // The layout declares letterSpacing="0.04"; tighten it exactly once. + val expected = 0.04f + style.letterSpacingDelta + assertEquals(expected, tvName.letterSpacing, 1e-6f) + + // Now the dialog decor sweep reaches the very same row. + val dialog = Dialog(activity) + dialog.setContentView(row) + FontPreference.applyTo(dialog) + + assertEquals(expected, tvName.letterSpacing, 1e-6f) + } + + /** Line spacing is scaled from the view's own multiplier, and its extra + * leading (set in dp by several layouts) is carried through untouched. */ + @Test fun openDyslexicScalesLineSpacingFromTheViewsOwnValues() { + this.setFont("opendyslexic") + val style = FontPreference.fontStyle(this.context)!! + + val tv = TextView(this.context).apply { this.setLineSpacing(4f, 1.1f) } + style.applyTo(tv) + + assertEquals(1.1f * style.lineSpacingFactor, tv.lineSpacingMultiplier, 1e-6f) + assertEquals("extra leading must be preserved", 4f, tv.lineSpacingExtra, 1e-6f) + } + + /** Swapping the family must keep each view's own bold/italic styling. */ + @Test fun applyToPreservesBoldStyle() { + this.setFont("opendyslexic") + val style = FontPreference.fontStyle(this.context)!! + + val tv = TextView(this.context).apply { this.setTypeface(null, Typeface.BOLD) } + style.applyTo(tv) + + assertTrue("bold must survive the family swap", tv.typeface.isBold) } /** applyTo is a harmless no-op when the system font is selected. */ From 3275957844861c6340fb05daee76a22c1913ce14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 22:00:47 +0000 Subject: [PATCH 4/4] Pin the bold-preservation test to a built-in font family applyToPreservesBoldStyle asserted isBold on a TextView after swapping in the bundled OpenDyslexic family, which made it a test of how faithfully the test runtime derives a bold weight from the OTF font-family resource rather than of our own behaviour, and it failed on that. The behaviour actually worth pinning is that applyTo carries each view's existing style through to setTypeface instead of dropping it. Assert that with Typeface.MONOSPACE, whose styling is reliable, and compare the resulting style directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GosWNcfE3dfMAmHCn4WNoy --- .../java/be/robinj/distrohopper/FontPreferenceTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt index 7a07dd1b..a043f47b 100644 --- a/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt @@ -174,15 +174,16 @@ class FontPreferenceTest { assertEquals("extra leading must be preserved", 4f, tv.lineSpacingExtra, 1e-6f) } - /** Swapping the family must keep each view's own bold/italic styling. */ + /** Swapping the family must keep each view's own bold/italic styling. Uses a + * built-in family so this pins our style pass-through rather than how + * faithfully the test runtime derives weights from a bundled OTF. */ @Test fun applyToPreservesBoldStyle() { - this.setFont("opendyslexic") - val style = FontPreference.fontStyle(this.context)!! + val style = FontStyle(Typeface.MONOSPACE, letterSpacingDelta = -0.05f) val tv = TextView(this.context).apply { this.setTypeface(null, Typeface.BOLD) } style.applyTo(tv) - assertTrue("bold must survive the family swap", tv.typeface.isBold) + assertEquals("bold must survive the family swap", Typeface.BOLD, tv.typeface.style) } /** applyTo is a harmless no-op when the system font is selected. */