Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c67e94d93 |
@@ -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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -196,26 +197,18 @@ fun rememberTimelinePinchZoom(
|
|||||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||||
*
|
*
|
||||||
* The ceiling is pulled onto that grid too, downwards, so it stays a height the
|
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||||
* pinch can actually land on.
|
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||||
*
|
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||||
* [fillPx] is deliberately *not* rounded (#290). It is the one height the whole
|
* pinch can be held against but never actually land on, and the difference feeds
|
||||||
* day exactly fills the viewport at, and it is the same value
|
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||||
* [TimelineScale.FitDay] resolves to — rounding it up by the fraction of a pixel
|
|
||||||
* that 24 hours don't divide the viewport into leaves the timeline a pixel per
|
|
||||||
* hour taller than its own viewport, so a pinched-all-the-way-out day still
|
|
||||||
* scrolls a hair and bounces off Android's overscroll stretch, while the
|
|
||||||
* identical FitDay preset sits still. Being the clamp result rather than a bound
|
|
||||||
* the gesture is merely held against, it is a height the pinch does land on: the
|
|
||||||
* next frame reads it back unchanged and the focal anchor is handed nothing to
|
|
||||||
* correct.
|
|
||||||
*/
|
*/
|
||||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||||
// the two to disagree, dead space is the worse of the two failures.
|
// the two to disagree, dead space is the worse of the two failures.
|
||||||
target.roundToInt().toFloat()
|
target.roundToInt().toFloat()
|
||||||
.coerceAtMost(floor(maxPx))
|
.coerceAtMost(floor(maxPx))
|
||||||
.coerceAtLeast(fillPx)
|
.coerceAtLeast(ceil(fillPx))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
|
|||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.trimmedLines
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
@@ -141,8 +140,7 @@ import kotlin.time.Clock
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.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). */
|
/** 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()
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = titleOverflow.overflow,
|
||||||
softWrap = titleOverflow.softWrap,
|
softWrap = titleOverflow.softWrap,
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
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.monthBarShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -2545,7 +2544,7 @@ private fun MonthBar(
|
|||||||
val titleOverflow = eventTitleOverflow()
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = titleOverflow.overflow,
|
||||||
softWrap = titleOverflow.softWrap,
|
softWrap = titleOverflow.softWrap,
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracking the two label roles the calendar grids are set in. Material gives
|
* Default Material 3 Expressive typography. Custom font + tuned scale will
|
||||||
* both 0.5sp, tuned for isolated UI labels with room around them; a month chip
|
* land in a later UI-design iteration; the defaults are intentional for V1
|
||||||
* is a text box some 37dp wide, where 0.5sp on an 11sp glyph spends most of a
|
* scaffolding to keep the foundation lean.
|
||||||
* character on spacing alone. 0.1sp is what Material itself sets labelLarge to,
|
|
||||||
* so the label family stays coherent (#190).
|
|
||||||
*/
|
*/
|
||||||
private val LabelTracking = 0.1.sp
|
val CalendulaTypography = Typography()
|
||||||
|
|
||||||
/**
|
|
||||||
* 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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventChipShape
|
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.NowLine
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -156,8 +155,7 @@ import kotlin.time.Clock
|
|||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/** One lane of the all-day strip, sized to a trimmed bar line (#190). */
|
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||||
private val ALL_DAY_ROW_HEIGHT = 20.dp
|
|
||||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||||
private val COLUMN_GAP = 2.dp
|
private val COLUMN_GAP = 2.dp
|
||||||
@@ -666,7 +664,7 @@ private fun AllDayBar(
|
|||||||
val titleOverflow = eventTitleOverflow()
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall.trimmedLines(),
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = titleOverflow.overflow,
|
overflow = titleOverflow.overflow,
|
||||||
softWrap = titleOverflow.softWrap,
|
softWrap = titleOverflow.softWrap,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
@@ -76,16 +75,17 @@ class TimelineZoomTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a pinch held against either bound stays put`() {
|
fun `a pinch held against a fractional bound stays put`() {
|
||||||
|
// A bound that is not a whole pixel is a height the pinch can be pushed
|
||||||
|
// against but never land on, so every frame of a held gesture would look
|
||||||
|
// like a scale change and hand the focal anchor a scroll correction.
|
||||||
val fillPx = 62.083f
|
val fillPx = 62.083f
|
||||||
val maxPx = 616.5f
|
val maxPx = 616.5f
|
||||||
|
|
||||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||||
|
|
||||||
// The ceiling is pulled onto the pixel grid so it stays landable; the
|
assertThat(floor).isEqualTo(63f)
|
||||||
// fill floor is landable as it is, being the clamp result itself.
|
|
||||||
assertThat(floor).isEqualTo(fillPx)
|
|
||||||
assertThat(ceiling).isEqualTo(616f)
|
assertThat(ceiling).isEqualTo(616f)
|
||||||
// Landing there and being pushed further must not move them again.
|
// Landing there and being pushed further must not move them again.
|
||||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||||
@@ -100,35 +100,6 @@ class TimelineZoomTest {
|
|||||||
assertThat(floor * 24).isAtLeast(viewport)
|
assertThat(floor * 24).isAtLeast(viewport)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `pinching all the way out leaves nothing to scroll`() {
|
|
||||||
// #290: rounding the fill floor up to a whole pixel made the day one
|
|
||||||
// pixel per hour taller than the viewport it was supposed to fill, so a
|
|
||||||
// fully zoomed-out timeline still scrolled a hair and bounced off
|
|
||||||
// Android's overscroll stretch -- while FitDay, at the same zoom, sat
|
|
||||||
// still. 1490 is deliberately not divisible by 24.
|
|
||||||
val viewport = 1490f
|
|
||||||
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
|
|
||||||
assertThat(floor * 24).isWithin(0.01f).of(viewport)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `the pinch floor is the height FitDay resolves to`() {
|
|
||||||
// The inconsistency the issue is about: the two ways to reach "the whole
|
|
||||||
// day on one screen" have to arrive at the same height.
|
|
||||||
val density = Density(2.5f)
|
|
||||||
val viewport = 596.dp
|
|
||||||
with(density) {
|
|
||||||
val fitDay = TimelineScale.FitDay.hourHeight(viewport).toPx()
|
|
||||||
val pinched = pinchedHourHeightPx(
|
|
||||||
target = 1f,
|
|
||||||
fillPx = fillHourHeight(viewport).toPx(),
|
|
||||||
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
|
||||||
)
|
|
||||||
assertThat(pinched).isWithin(0.01f).of(fitDay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a settled pinch is what gets persisted`() {
|
fun `a settled pinch is what gets persisted`() {
|
||||||
var persisted: TimelineScale? = null
|
var persisted: TimelineScale? = null
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
@@ -49,19 +48,6 @@ class FontsTest {
|
|||||||
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
|
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
|
@Test
|
||||||
fun `a null role keeps that role's default family while the other is applied`() {
|
fun `a null role keeps that role's default family while the other is applied`() {
|
||||||
val plain = BundledFont.Lora.family
|
val plain = BundledFont.Lora.family
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ kotlinxDatetime = "0.7.0"
|
|||||||
kotlinxCoroutines = "1.11.0"
|
kotlinxCoroutines = "1.11.0"
|
||||||
turbine = "1.2.1"
|
turbine = "1.2.1"
|
||||||
hiltNavigationCompose = "1.4.0"
|
hiltNavigationCompose = "1.4.0"
|
||||||
lifecycleCompose = "2.10.0"
|
lifecycleCompose = "2.11.0"
|
||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||||
glance = "1.1.1"
|
glance = "1.1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user