Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c247b9bc9 | ||
|
|
6bb91c480b | ||
|
|
49d21903c6 | ||
|
|
9b8da92157 |
@@ -32,6 +32,68 @@ val BLOCK_TEXT_PADDING = 4.dp
|
||||
/** The same, above and below — what a block's height has to pay before any text. */
|
||||
val BLOCK_TEXT_INSET = 2.dp
|
||||
|
||||
/**
|
||||
* What a timed block of a given height has to spend on text, and what each line
|
||||
* of it costs.
|
||||
*/
|
||||
@Immutable
|
||||
data class BlockTextMetrics(
|
||||
/** Padding above and below the text — see [rememberBlockTextMetrics]. */
|
||||
val inset: Dp,
|
||||
/** Height left for text once [inset] is paid at both edges. */
|
||||
val available: Dp,
|
||||
/** What the first line of a title draws in. */
|
||||
val titleLine: Dp,
|
||||
/** What every title line after the first adds. */
|
||||
val titleLeading: Dp,
|
||||
/** What the time label's one line draws in. */
|
||||
val timeLine: Dp,
|
||||
) {
|
||||
/** Whether the block can draw a title at all. */
|
||||
val fitsTitle: Boolean get() = available >= titleLine
|
||||
|
||||
/** Height a title of [lines] lines occupies. */
|
||||
fun titleHeight(lines: Int): Dp =
|
||||
if (lines <= 0) 0.dp else titleLine + titleLeading * (lines - 1)
|
||||
|
||||
/** Title lines that fit [within], which may be none. */
|
||||
fun titleBudget(within: Dp): Int =
|
||||
if (within < titleLine) 0 else 1 + ((within - titleLine) / titleLeading).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The vertical padding a block [height] tall can afford around a title line of
|
||||
* [titleLine].
|
||||
*
|
||||
* The inset is what the block gives up first: breathing room is worth having
|
||||
* where there is room to breathe, but on a block down to its last few pixels a
|
||||
* bare colour chip where a label would have fit reads as a rendering fault. It
|
||||
* tapers rather than snapping, so a pinch closes the gap gradually instead of
|
||||
* dropping it in one frame (#289).
|
||||
*/
|
||||
internal fun blockTextInset(height: Dp, titleLine: Dp): Dp =
|
||||
minOf(BLOCK_TEXT_INSET, (height - titleLine) / 2).coerceAtLeast(0.dp)
|
||||
|
||||
/** Text metrics for a timed block [height] tall. */
|
||||
@Composable
|
||||
fun rememberBlockTextMetrics(height: Dp): BlockTextMetrics {
|
||||
val titleStyle = MaterialTheme.typography.labelMedium
|
||||
val titleLine = rememberTrimmedLineHeight(titleStyle)
|
||||
// Packed, so a second line costs what the first did rather than a whole
|
||||
// Material line box — the gap between two lines of a wrapped title is the
|
||||
// one place a block pays that leading twice (#190).
|
||||
val titleLeading = titleLine
|
||||
val timeLine = rememberTrimmedLineHeight(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
val inset = blockTextInset(height, titleLine)
|
||||
return BlockTextMetrics(
|
||||
inset = inset,
|
||||
available = height - inset * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = titleLeading,
|
||||
timeLine = timeLine,
|
||||
)
|
||||
}
|
||||
|
||||
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||
const val MAX_TIME_LINES = 2
|
||||
|
||||
@@ -70,9 +132,9 @@ fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int
|
||||
*/
|
||||
@Composable
|
||||
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
|
||||
val timeLineHeight = with(LocalDensity.current) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = rememberTrimmedLineHeight(
|
||||
MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
)
|
||||
return if (spare >= timeLineHeight) {
|
||||
blockTextLines(
|
||||
text = label,
|
||||
@@ -108,8 +170,10 @@ fun BlockTitle(
|
||||
Text(
|
||||
text = title,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
style = rememberPackedLines(
|
||||
MaterialTheme.typography.labelMedium
|
||||
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
|
||||
),
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
@@ -141,6 +205,8 @@ fun BlockTimeLabel(
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
val style = rememberPackedLines(MaterialTheme.typography.labelSmall.asEventTime())
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
@@ -149,8 +215,7 @@ fun BlockTimeLabel(
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
// Regular weight against the title's medium above it (#219).
|
||||
style = MaterialTheme.typography.labelSmall.asEventTime(),
|
||||
style = style,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -790,20 +790,15 @@ private fun DragCopy(
|
||||
val width = with(density) { sizePx.width.toDp() }
|
||||
val height = with(density) { sizePx.height.toDp() }
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// The block's size, spent the block's way — text sits at the top as it does
|
||||
// on the block, so the copy hands back to the grid without shifting (#267).
|
||||
// on the block, so the copy hands back to the grid without shifting (#267),
|
||||
// and it squeezes its inset on the same terms so a short block's title does
|
||||
// not vanish the moment it is lifted (#289).
|
||||
// The title is served in full first and the range lives off what is left:
|
||||
// the hour gutter down the side still says where the copy sits, so the
|
||||
// range is the half that can afford to go.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
|
||||
val allowed = titleLines.coerceAtMost(titleBudget)
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
val allowed = titleLines.coerceAtMost(metrics.titleBudget(metrics.available))
|
||||
// Re-measured at the copy's own width rather than spent on the source's
|
||||
// count: a block sharing its column with another is a lane wide where the
|
||||
// copy is a whole column, so the source's second line is one the copy never
|
||||
@@ -818,10 +813,10 @@ private fun DragCopy(
|
||||
max = allowed,
|
||||
)
|
||||
}
|
||||
val left = available - titleLineHeight * lines
|
||||
val showTime = label != null && left >= timeLineHeight
|
||||
val left = metrics.available - metrics.titleHeight(lines)
|
||||
val showTime = label != null && left >= metrics.timeLine
|
||||
val timeMaxLines = if (showTime) {
|
||||
blockTimeLines(label!!, textWidth, left - timeLineHeight)
|
||||
blockTimeLines(label!!, textWidth, left - metrics.timeLine)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
@@ -857,7 +852,7 @@ private fun DragCopy(
|
||||
clip = false
|
||||
}
|
||||
.eventSurface(paint, shape, cuts)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset),
|
||||
) {
|
||||
Column {
|
||||
if (lines > 0) {
|
||||
|
||||
@@ -83,9 +83,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
@@ -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,
|
||||
@@ -763,30 +765,24 @@ private fun EventBlock(
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// A block that cannot afford both lines spends its space on the title, and
|
||||
// one too short even for that drops the title rather than serving a sliced one.
|
||||
// Height alone decides: a duration threshold would keep hiding the time on a
|
||||
// half-hour block the user has pinched open to three times the room it needs.
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val showTime = available >= titleLineHeight + timeLineHeight
|
||||
val showTitle = available >= titleLineHeight
|
||||
val showTime = metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val showTitle = metrics.fitsTitle
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// Only lines the block can actually draw: a block too short for the time is
|
||||
// too short for a second title line too, and asking for one served a sliced
|
||||
// one — as well as handing the drag copy a count it couldn't honour, so the
|
||||
// title re-wrapped the moment the block was lifted (#267).
|
||||
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val titleBudget = metrics.titleBudget(metrics.available).coerceAtLeast(1)
|
||||
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(2)
|
||||
// On a day column — wide enough for "09:30–11:00" several times over — the
|
||||
// range never needs the second line, until lanes cut the column down.
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val paint = eventPaint(block.event, dark)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
@@ -827,7 +823,7 @@ private fun EventBlock(
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,11 +91,11 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberBlockTextMetrics
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
@@ -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,
|
||||
@@ -909,13 +911,7 @@ private fun EventBlock(
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
val available = height - BLOCK_TEXT_INSET * 2
|
||||
val metrics = rememberBlockTextMetrics(height)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||
@@ -923,19 +919,19 @@ private fun EventBlock(
|
||||
// its own: a duration threshold would keep hiding the time on a half-hour
|
||||
// block the user has pinched open to three times the room it needs.
|
||||
val showTime = block.laneCount == 1 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
metrics.available >= metrics.titleLine + metrics.timeLine
|
||||
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||
// the semantics description carries the full title either way.
|
||||
val showTitle = available >= titleLineHeight
|
||||
val showTitle = metrics.fitsTitle
|
||||
// The title is served first, out of everything the block has left once the
|
||||
// time is down to one line — but only takes the lines it will actually use,
|
||||
// and only wraps at all once a line is wide enough to hold more than a
|
||||
// syllable. Below that the extra lines just stack fragments of the word.
|
||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val contentHeight = metrics.available - if (showTime) metrics.timeLine else 0.dp
|
||||
val titleBudget = metrics.titleBudget(contentHeight).coerceAtLeast(1)
|
||||
val paint = eventPaint(block.event, dark)
|
||||
// Every line the height affords, however narrow the lane: two events side by
|
||||
// side leave columns well under a word wide, and cutting the title to one
|
||||
@@ -948,8 +944,8 @@ private fun EventBlock(
|
||||
textWidth = textWidth,
|
||||
max = titleBudget,
|
||||
)
|
||||
val spare = available - titleLineHeight * titleMaxLines -
|
||||
if (showTime) timeLineHeight else 0.dp
|
||||
val spare = metrics.available - metrics.titleHeight(titleMaxLines) -
|
||||
if (showTime) metrics.timeLine else 0.dp
|
||||
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||
@@ -991,7 +987,7 @@ private fun EventBlock(
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = metrics.inset)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BlockTextMetricsTest {
|
||||
|
||||
/** One trimmed labelMedium line at font scale 1: a 12sp glyph, leading off. */
|
||||
private val titleLine = 14.dp
|
||||
|
||||
/** A block with room to spare, for the line arithmetic. */
|
||||
private fun metrics(height: Dp = 100.dp) = BlockTextMetrics(
|
||||
inset = blockTextInset(height, titleLine),
|
||||
available = height - blockTextInset(height, titleLine) * 2,
|
||||
titleLine = titleLine,
|
||||
titleLeading = 16.dp,
|
||||
timeLine = 13.dp,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a block with room keeps the full inset`() {
|
||||
assertThat(blockTextInset(height = 60.dp, titleLine = titleLine))
|
||||
.isEqualTo(BLOCK_TEXT_INSET)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the inset tapers instead of snapping as the block shrinks`() {
|
||||
// Half the inset left over is half the inset kept, so a pinch closes the
|
||||
// gap frame by frame rather than dropping it in one.
|
||||
assertThat(blockTextInset(height = titleLine + 2.dp, titleLine = titleLine))
|
||||
.isEqualTo(1.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block exactly one title tall spends nothing on padding`() {
|
||||
assertThat(blockTextInset(height = titleLine, titleLine = titleLine)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block shorter than a line never insets negatively`() {
|
||||
assertThat(blockTextInset(height = 4.dp, titleLine = titleLine)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the title survives a block that used to be too short for it`() {
|
||||
// 18dp is under the old floor — a title line plus 2dp of inset at each
|
||||
// edge — and over the new one, which is the line on its own (#289).
|
||||
val m = metrics(height = 18.dp)
|
||||
assertThat(m.fitsTitle).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block under one line still drops the title`() {
|
||||
assertThat(metrics(height = 10.dp).fitsTitle).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every title line after the first costs a whole line box`() {
|
||||
// The trim reaches the outer edges only, so the leading between two
|
||||
// lines is still there to pay for.
|
||||
val m = metrics()
|
||||
assertThat(m.titleHeight(1)).isEqualTo(14.dp)
|
||||
assertThat(m.titleHeight(2)).isEqualTo(30.dp)
|
||||
assertThat(m.titleHeight(3)).isEqualTo(46.dp)
|
||||
assertThat(m.titleHeight(0)).isEqualTo(0.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the budget is what the block can actually draw, not what divides into it`() {
|
||||
val m = metrics()
|
||||
// Two lines cost 30dp: 29 buys one, 30 buys the second.
|
||||
assertThat(m.titleBudget(29.dp)).isEqualTo(1)
|
||||
assertThat(m.titleBudget(30.dp)).isEqualTo(2)
|
||||
assertThat(m.titleBudget(13.dp)).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a budget line is always one the block can pay for`() {
|
||||
val m = metrics()
|
||||
(0..80).forEach { dp ->
|
||||
val within = dp.dp
|
||||
val budget = m.titleBudget(within)
|
||||
assertThat(m.titleHeight(budget)).isAtMost(within)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user