From a0595c9b624148d34f6af4aec32ba9dfbc12cb57 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:00:18 +0000 Subject: [PATCH 1/2] 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/2] 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. */