Compare commits

..
Author SHA1 Message Date
makiolaj c82d6edcf0 Size the week-number gutter to the number it seats (#213, #189) 2026-09-20 22:56:03 +02:00
6 changed files with 45 additions and 110 deletions
@@ -1,57 +0,0 @@
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() }
}
}
@@ -115,7 +115,6 @@ 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
@@ -141,8 +140,7 @@ import kotlin.time.Clock
import java.util.Locale
import kotlin.math.roundToInt
/** 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_ROW_HEIGHT = 24.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). */
@@ -531,7 +529,7 @@ private fun AllDayBar(
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
@@ -123,6 +123,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -153,7 +154,6 @@ 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
@@ -745,7 +745,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
) {
// Reserve the gutter so the weekday labels stay over their day columns.
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
if (showWeekNumbers) Spacer(Modifier.width(rememberWeekNumberGutter()))
days.forEach { dow ->
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
@@ -763,9 +763,34 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
private val EVENT_ROW_HEIGHT = 20.dp
private val DAY_NUMBER_HEIGHT = 22.dp
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
* seats a one- or two-digit week number in a full-height tonal pill. */
private val WEEK_NUMBER_GUTTER = 40.dp
/** Padding between the week-number pill's edge and the number inside it. */
private val WEEK_NUMBER_PADDING = 6.dp
/** The widest week number an ISO year reaches; digits are tabular, so one
* measurement of it prices every week in the grid. */
private const val WEEK_NUMBER_SAMPLE = "53"
/**
* Width of the optional left calendar-week gutter (#25), measured rather than
* fixed: it is sized to the number it seats at the style the pill draws it in,
* so it follows the font scale instead of reserving slack for it, and spends
* nothing more on a column the grid would rather hand to the seven days (#213).
*/
@Composable
private fun rememberWeekNumberGutter(): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val style = weekNumberStyle()
return remember(style, density, measurer) {
val text = with(density) { measurer.measure(WEEK_NUMBER_SAMPLE, style).size.width.toDp() }
text + (WEEK_NUMBER_PADDING + CELL_GAP) * 2
}
}
/** The week number's own style — a step down from the day numbers beside it. */
@Composable
private fun weekNumberStyle() =
MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)
private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp
/** Named separately because the split style's selection outline draws its own
@@ -1487,7 +1512,7 @@ internal fun SplitMonthGrid(
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.width(WEEK_NUMBER_GUTTER)
.width(rememberWeekNumberGutter())
.fillMaxHeight(),
)
}
@@ -2008,7 +2033,7 @@ private fun MonthWeekRow(
WeekNumberGutter(
weekStart = week.days.first(),
modifier = Modifier
.width(WEEK_NUMBER_GUTTER)
.width(rememberWeekNumberGutter())
.fillMaxHeight(),
)
}
@@ -2423,8 +2448,7 @@ private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
style = weekNumberStyle(),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
@@ -2545,7 +2569,7 @@ private fun MonthBar(
val titleOverflow = eventTitleOverflow()
Text(
text = label,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
@@ -1,24 +1,10 @@
package de.jeanlucmakiola.calendula.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.unit.sp
/**
* 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).
* 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.
*/
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),
)
}
val CalendulaTypography = Typography()
@@ -118,7 +118,6 @@ 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
@@ -156,8 +155,7 @@ import kotlin.time.Clock
import java.time.format.TextStyle as JavaTextStyle
import java.util.Locale
/** 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_ROW_HEIGHT = 24.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
@@ -573,7 +571,7 @@ private fun WeekDayHeader(
}
/** Calendar-week badge shown in the header gutter, deliberately set apart with a
* filled box and bold number. */
* filled box and bold number — at the month grid's size, so the two agree (#213). */
@Composable
private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
val label = stringResource(R.string.week_number_label)
@@ -585,9 +583,9 @@ private fun WeekNumberBadge(weekNumber: Int, modifier: Modifier = Modifier) {
) {
Text(
text = weekNumber.toString(),
style = MaterialTheme.typography.titleSmall,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
@@ -666,7 +664,7 @@ private fun AllDayBar(
val titleOverflow = eventTitleOverflow()
Text(
text = title,
style = MaterialTheme.typography.labelSmall.trimmedLines(),
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = titleOverflow.overflow,
softWrap = titleOverflow.softWrap,
@@ -1,6 +1,5 @@
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
@@ -49,19 +48,6 @@ 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