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..49380235 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,19 @@ 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.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_DELTA = -0.05f + private const val OPENDYSLEXIC_LINE_SPACING_FACTOR = 0.8f + /** Bundled font resource for [value], or null for System / unknown values. */ @FontRes fun fontResFor(value: String?): Int? = when (value) { @@ -42,6 +55,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_DELTA, + OPENDYSLEXIC_LINE_SPACING_FACTOR, + ) + } else { + FontStyle(typeface) + } + } + private fun current(context: Context): String = Preferences.getSharedPreferences(context) .getString(Preference.FONT.getName(), SYSTEM) ?: SYSTEM @@ -54,7 +87,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 +97,7 @@ object FontPreference { LayoutInflaterCompat.setFactory2( inflater, - FontInflaterFactory(activity.delegate, typeface), + FontInflaterFactory(activity.delegate, fontStyle), ) } @@ -77,19 +110,55 @@ 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, 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 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) + + // 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 77a73c27..b65323ec 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,41 @@ class FontPreferenceTest { assertEquals(Typeface.MONOSPACE, (view as TextView).typeface) } + /** 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 delta should be negative", style!!.letterSpacingDelta < 0f) + assertTrue("line spacing factor should be < 1", style.lineSpacingFactor < 1f) + + val tv = TextView(this.context).apply { + this.letterSpacing = 0.04f // explicit tracking, e.g. from a style + this.setLineSpacing(0f, 1f) + } + style.applyTo(tv) + // 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 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!!.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. */ @Test fun applyToIsNoOpForSystem() { this.setFont("system")