Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
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
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
Expand All @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -64,7 +97,7 @@ object FontPreference {

LayoutInflaterCompat.setFactory2(
inflater,
FontInflaterFactory(activity.delegate, typeface),
FontInflaterFactory(activity.delegate, fontStyle),
)
}

Expand All @@ -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,
)
}
}
}
38 changes: 37 additions & 1 deletion app/src/test/java/be/robinj/distrohopper/FontPreferenceTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,14 +61,49 @@ 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)

assertTrue(view is TextView)
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")
Expand Down
Loading