From 714f45d8696351a95af613070593547862dc232a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 23 Sep 2026 17:11:10 +0200 Subject: [PATCH] Tighten label tracking so the grids fit more text (#316) --- .../calendula/ui/common/CalendarText.kt | 57 +++++++++++++++++++ .../calendula/ui/day/DayScreen.kt | 6 +- .../calendula/ui/month/MonthScreen.kt | 3 +- .../jeanlucmakiola/calendula/ui/theme/Type.kt | 22 +++++-- .../calendula/ui/week/WeekScreen.kt | 6 +- .../calendula/ui/theme/FontsTest.kt | 14 +++++ 6 files changed, 99 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt new file mode 100644 index 0000000..cec1580 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarText.kt @@ -0,0 +1,57 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.unit.Dp + +/** + * The calendar surfaces' line-height treatment. Material wraps an 11sp glyph in + * a 16sp line box — room a label wants when it stands on its own, and close to + * a fifth of an event row when it doesn't. The outer edges only, so a wrapped + * title keeps its interior line spacing (#190). + * + * Trimmed rather than set to a smaller line height: the font picker can load a + * serif, a monospace or a file of the user's own, and a line height under a + * face's own ascent and descent overlaps its lines. There is nothing to trim + * below that, so this is safe whatever font is chosen. + */ +private val TrimmedLines = LineHeightStyle( + alignment = LineHeightStyle.Alignment.Center, + trim = LineHeightStyle.Trim.Both, +) + +/** Ascenders and descenders both, so a line is measured at its full extent. */ +private const val LINE_SAMPLE = "Ag" + +/** [this] with Material's outer leading trimmed — see [TrimmedLines]. */ +fun TextStyle.trimmedLines(): TextStyle = copy(lineHeightStyle = TrimmedLines) + +/** + * [style] with its wrapped lines packed onto the face's own extent instead of + * Material's line box. + * + * [trimmedLines] takes the leading off the outer edges of a run of text; this + * takes it from between the lines as well, which is the half a wrapped event + * title pays for twice over. The line height is *measured* from the font rather + * than picked, so it lands exactly on the face's ascent-plus-descent and can + * never be short enough to overlap — whatever the font picker has loaded (#190). + */ +@Composable +fun rememberPackedLines(style: TextStyle): TextStyle { + val line = rememberTrimmedLineHeight(style) + return with(LocalDensity.current) { style.trimmedLines().copy(lineHeight = line.toSp()) } +} + +/** What one trimmed line of [style] actually draws in. */ +@Composable +fun rememberTrimmedLineHeight(style: TextStyle): Dp { + val measurer = rememberTextMeasurer() + val density = LocalDensity.current + return remember(style, density, measurer) { + with(density) { measurer.measure(LINE_SAMPLE, style.trimmedLines()).size.height.toDp() } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index ba70d12..1639b0d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -115,6 +115,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat @@ -140,7 +141,8 @@ import kotlin.time.Clock import java.util.Locale import kotlin.math.roundToInt -private val ALL_DAY_ROW_HEIGHT = 24.dp +/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ +private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp /** Total all-day strip height for the day (0 when there are no all-day events). */ @@ -529,7 +531,7 @@ private fun AllDayBar( val titleOverflow = eventTitleOverflow() Text( text = title, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.trimmedLines(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 4b5d54b..20f94e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -153,6 +153,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill @@ -2544,7 +2545,7 @@ private fun MonthBar( val titleOverflow = eventTitleOverflow() Text( text = label, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.trimmedLines(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt index 239374b..cc3e18e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Type.kt @@ -1,10 +1,24 @@ package de.jeanlucmakiola.calendula.ui.theme import androidx.compose.material3.Typography +import androidx.compose.ui.unit.sp /** - * Default Material 3 Expressive typography. Custom font + tuned scale will - * land in a later UI-design iteration; the defaults are intentional for V1 - * scaffolding to keep the foundation lean. + * Tracking the two label roles the calendar grids are set in. Material gives + * both 0.5sp, tuned for isolated UI labels with room around them; a month chip + * is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a + * character on spacing alone. 0.1sp is what Material itself sets labelLarge to, + * so the label family stays coherent (#190). */ -val CalendulaTypography = Typography() +private val LabelTracking = 0.1.sp + +/** + * Material 3 Expressive typography with the label roles' tracking tightened. + * Everything else is the default scale. + */ +val CalendulaTypography: Typography = Typography().let { base -> + base.copy( + labelMedium = base.labelMedium.copy(letterSpacing = LabelTracking), + labelSmall = base.labelSmall.copy(letterSpacing = LabelTracking), + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 7c55ce1..7380709 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -118,6 +118,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController import de.jeanlucmakiola.calendula.ui.common.startInstant import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.EventChipShape +import de.jeanlucmakiola.calendula.ui.common.trimmedLines import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill @@ -155,7 +156,8 @@ import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale -private val ALL_DAY_ROW_HEIGHT = 24.dp +/** One lane of the all-day strip, sized to a trimmed bar line (#190). */ +private val ALL_DAY_ROW_HEIGHT = 20.dp private val ALL_DAY_VERTICAL_PADDING = 6.dp /** Gap between day columns; part of the column pitch a drag maps positions through. */ private val COLUMN_GAP = 2.dp @@ -664,7 +666,7 @@ private fun AllDayBar( val titleOverflow = eventTitleOverflow() Text( text = title, - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.labelSmall.trimmedLines(), maxLines = 1, overflow = titleOverflow.overflow, softWrap = titleOverflow.softWrap, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt index ab84941..8b0766f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/theme/FontsTest.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.ui.theme +import androidx.compose.ui.unit.sp import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test @@ -48,6 +49,19 @@ class FontsTest { assertThat(typography.labelSmall.fontFamily).isEqualTo(plain) } + @Test + fun `picking a font keeps the tightened label tracking`() { + // The font picker only swaps the family; the tracking the grids are laid + // out against has to survive it (#190). + val typography = calendulaTypography( + brand = BundledFont.Lora.family, + plain = BundledFont.AtkinsonHyperlegible.family, + ) + + assertThat(typography.labelSmall.letterSpacing).isEqualTo(0.1.sp) + assertThat(typography.labelMedium.letterSpacing).isEqualTo(0.1.sp) + } + @Test fun `a null role keeps that role's default family while the other is applied`() { val plain = BundledFont.Lora.family