Adjustable vertical scale for the week and day timeline (#56) (#122)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped

Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/122
This commit is contained in:
Jean-Luc Makiola
2026-07-31 20:49:56 +02:00
parent 3d62036d79
commit 9054742503
29 changed files with 1548 additions and 243 deletions

View File

@@ -5,6 +5,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@@ -98,6 +100,31 @@ class SettingsPrefsTest {
assertThat(prefs.showHourLines.first()).isTrue()
}
@Test
fun `timeline scale defaults to regular and round-trips`(@TempDir tempDir: Path) = runTest {
// Regular is the historical 56dp scale — an existing install that never
// opened the setting must keep the timeline it had (#56).
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Regular)
prefs.setTimelineScale(TimelineScale.FitDay)
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.FitDay)
}
@Test
fun `a pinched timeline height round-trips`(@TempDir tempDir: Path) = runTest {
// The pinch settles on a height between the presets, so the preference
// has to store the number, not just a named step (#56).
val prefs = SettingsPrefs(newDataStore(tempDir))
val pinched = TimelineScale.custom(63.5f.dp)
prefs.setTimelineScale(pinched)
assertThat(prefs.timelineScale.first()).isEqualTo(pinched)
// …and a preset picked afterwards replaces it, rather than the two
// coexisting with one silently winning.
prefs.setTimelineScale(TimelineScale.Comfortable)
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable)
}
@Test
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))

View File

@@ -74,10 +74,10 @@ class EventColorPaletteTest {
}
@Test
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
// Black and every gray paint as the same pale swatch (the picker pins
// lightness and floors saturation), so only one survives — no stranded
// run of look-alike "pinks" at the end of the grid (#22).
fun `neutrals collapse instead of forming a run of look-alikes`() {
// Neutrals lose their hue entirely when painted, so they collapse onto
// the one or two greys the poles offer — a dark and a pale — instead of
// the old stranded run of look-alike "pinks" at the end of the grid (#22).
val curated = listOf(
EventColorOption("black", 0xFF000000.toInt()),
EventColorOption("gray", 0xFF808080.toInt()),
@@ -86,8 +86,11 @@ class EventColorPaletteTest {
EventColorOption("red", 0xFFFF0000.toInt()),
).curatedForPicker().map { it.key }
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
assertThat(curated).doesNotContain("gray") // paints as black's grey
assertThat(curated).containsAtLeast("black", "red", "blue")
// darkgray is light enough to keep a pale character, so the three
// neutrals still fold down to at most two swatches, never a run.
assertThat(curated.count { it in listOf("black", "gray", "darkgray") }).isAtMost(2)
}
@Test

View File

@@ -0,0 +1,159 @@
package de.jeanlucmakiola.calendula.domain.color
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class EventToneTest {
/** Hues at full chroma, standing in for the provider colours in the wild. */
private val hues = (0 until 360 step 15).map { Oklch(0.65f, 0.2f, it.toFloat()).toArgb() }
private fun relativeLuminance(argb: Int): Double {
fun channel(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
private fun contrast(a: Int, b: Int): Double {
val hi = maxOf(relativeLuminance(a), relativeLuminance(b))
val lo = minOf(relativeLuminance(a), relativeLuminance(b))
return (hi + 0.05) / (lo + 0.05)
}
/** The other ink, for asserting the chosen one is the better of the two. */
private fun flip(ink: Int) =
if (ink == 0xFFFFFFFF.toInt()) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
/** [ink] over [fill] at [alpha], the way a title actually renders. */
private fun composite(ink: Int, fill: Int, alpha: Double): Int {
fun mix(shift: Int): Int {
val i = (ink shr shift) and 0xFF
val f = (fill shr shift) and 0xFF
return (alpha * i + (1 - alpha) * f).toInt().coerceIn(0, 255)
}
return (0xFF shl 24) or (mix(16) shl 16) or (mix(8) shl 8) or mix(0)
}
@Test
fun `hues of one lightness all get the same ink`() {
// The bug this replaced: at a pinned HSV value the hues straddled the ink
// crossover, so red took white while orange beside it took black. Ink may
// still differ by *pole*, but never by hue within a pole.
for (dark in listOf(false, true)) {
val inks = hues.map { eventTone(it, dark, harmonise = true).onContainer }.toSet()
assertThat(inks).containsExactly(0xFFFFFFFF.toInt())
}
}
@Test
fun `naturally light colours keep their character instead of turning muddy`() {
// Forcing every hue deep made a yellow into olive and a cream into brown.
// Pale sources stay pale — and then take dark ink.
for (pale in listOf(0xFFF6BF26, 0xFFFFF8DC, 0xFFF0E68C, 0xFFFFFFFF)) {
val tone = eventTone(pale.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).lightness).isGreaterThan(0.8f)
assertThat(tone.onContainer).isEqualTo(0xFF000000.toInt())
}
}
@Test
fun `warm mid colours still go deep, so orange reads as orange and not as cream`() {
// The threshold has to sit above orange: a burnt orange still says
// "orange", which is why it belongs on the deep pole with white ink.
// Note the ceiling this implies: a *bright* orange (#FF8C00, #FFA500) is
// genuinely a light colour and takes the light pole instead — forcing it
// deep is exactly what produced brown.
for (warm in listOf(0xFFF4511E, 0xFFE67C73, 0xFFD50000, 0xFFE65100)) {
val tone = eventTone(warm.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).lightness).isLessThan(0.6f)
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
}
}
@Test
fun `title ink clears WCAG AA on every hue`() {
for (dark in listOf(false, true)) {
for (hue in hues) {
val tone = eventTone(hue, dark, harmonise = true)
val ink = composite(tone.onContainer, tone.container, 0.85)
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
assertThat(contrast(tone.container, ink))
.isGreaterThan(contrast(tone.container, composite(flip(tone.onContainer), tone.container, 0.85)))
}
}
}
@Test
fun `the secondary line also clears WCAG AA on every hue`() {
for (dark in listOf(false, true)) {
for (hue in hues) {
val tone = eventTone(hue, dark, harmonise = true)
val ink = composite(tone.onContainer, tone.container, 0.8)
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
}
}
}
@Test
fun `an accent stays visible against its own theme's surface`() {
// Accents carry no text, so the bar is WCAG's 3:1 for non-text contrast.
val lightSurface = 0xFFFEF7FF.toInt()
val darkSurface = 0xFF141218.toInt()
for ((dark, surface) in listOf(false to lightSurface, true to darkSurface)) {
for (hue in hues) {
val accent = eventTone(hue, dark, harmonise = true).accent
assertThat(contrast(accent, surface)).isGreaterThan(3.0)
}
}
}
@Test
fun `container and accent keep the same hue, so one calendar reads as one colour`() {
for (hue in hues) {
val tone = eventTone(hue, dark = false, harmonise = true)
assertThat(oklchOf(tone.accent).hue).isWithin(2f).of(oklchOf(tone.container).hue)
}
}
@Test
fun `orange no longer gets dark ink`() {
// The reported case: a plain orange calendar used to fall just above the
// crossover and take black text while its neighbours took white.
val tone = eventTone(0xFFF4511E.toInt(), dark = false, harmonise = true)
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
}
@Test
fun `different hues stay distinguishable`() {
val containers = hues.map { eventTone(it, dark = false, harmonise = true).container }
assertThat(containers.toSet()).hasSize(hues.size)
}
@Test
fun `a grey source stays grey rather than gaining an invented hue`() {
for (grey in listOf(0xFF000000, 0xFF808080, 0xFFFFFFFF, 0xFF9E9E9E)) {
val tone = eventTone(grey.toInt(), dark = false, harmonise = true)
assertThat(oklchOf(tone.container).chroma).isLessThan(0.01f)
}
}
@Test
fun `raw colours are painted verbatim when harmonising is off`() {
val raw = 0xFFF4511E.toInt()
val tone = eventTone(raw, dark = false, harmonise = false)
assertThat(tone.container).isEqualTo(raw)
assertThat(tone.accent).isEqualTo(raw)
}
@Test
fun `raw mode still picks a readable ink per colour`() {
// Nothing constrains what a provider sends, so the ink cannot be constant
// on this path the way it is for harmonised containers.
assertThat(eventTone(0xFF101010.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFFFFFFFF.toInt())
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
.isEqualTo(0xFF000000.toInt())
}
}

View File

@@ -0,0 +1,64 @@
package de.jeanlucmakiola.calendula.domain.color
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class OklchTest {
@Test
fun `matches Oklab's published anchors`() {
// Guards the conversion matrices against a transcription slip: these are
// Ottosson's own reference values for the space.
assertThat(oklchOf(0xFFFFFFFF.toInt()).lightness).isWithin(0.001f).of(1f)
assertThat(oklchOf(0xFF000000.toInt()).lightness).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFFFF0000.toInt()).lightness).isWithin(0.001f).of(0.6280f)
}
@Test
fun `white and black have no chroma`() {
assertThat(oklchOf(0xFFFFFFFF.toInt()).chroma).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFF000000.toInt()).chroma).isWithin(0.001f).of(0f)
assertThat(oklchOf(0xFF808080.toInt()).chroma).isWithin(0.001f).of(0f)
}
@Test
fun `converting to sRGB and back round-trips`() {
for (argb in listOf(0xFFF4511E, 0xFF039BE5, 0xFF0B8043, 0xFF8E24AA, 0xFFFBD75B)) {
val original = oklchOf(argb.toInt())
val roundTripped = oklchOf(original.toArgb())
assertThat(roundTripped.lightness).isWithin(0.01f).of(original.lightness)
assertThat(roundTripped.chroma).isWithin(0.01f).of(original.chroma)
assertThat(roundTripped.hue).isWithin(1f).of(original.hue)
}
}
@Test
fun `an out-of-gamut request keeps its lightness and gives up chroma`() {
// Most of the Oklch cylinder is outside sRGB. Lightness is the axis the
// contrast guarantees rest on, so it is the one that must survive.
val requested = Oklch(0.45f, 0.35f, 150f)
val actual = oklchOf(requested.toArgb())
assertThat(actual.lightness).isWithin(0.02f).of(requested.lightness)
assertThat(actual.chroma).isLessThan(requested.chroma)
}
@Test
fun `pinning lightness holds luminance far tighter than pinning HSV value did`() {
// The whole reason for the space swap. At a pinned HSV value the hues
// spread across relative luminance 0.10..0.45; pinned Oklch lightness
// must keep them in a narrow band, or one ink cannot serve them all.
val luminances = (0 until 360 step 15).map { hue ->
val argb = Oklch(0.45f, 0.16f, hue.toFloat()).toArgb()
relativeLuminance(argb)
}
assertThat(luminances.max() / luminances.min()).isLessThan(2.5)
}
private fun relativeLuminance(argb: Int): Double {
fun channel(shift: Int): Double {
val c = ((argb shr shift) and 0xFF) / 255.0
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
}
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
}
}

View File

@@ -0,0 +1,129 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class TimelineScaleTest {
/** A Pixel-7-ish timeline viewport: what the issue reporter is looking at. */
private val phoneViewport = 670.dp
@Test
fun `Regular is the timeline's original 56dp constant`() {
// The default must not move: an install that never opens the setting has
// to keep the week and day views it already had (#56).
assertThat(TimelineScale.Regular.hourHeight(phoneViewport)).isEqualTo(56.dp)
}
@Test
fun `the fixed scales ignore the viewport`() {
for (scale in listOf(TimelineScale.Compact, TimelineScale.Regular, TimelineScale.Comfortable)) {
assertThat(scale.hourHeight(200.dp)).isEqualTo(scale.hourHeight(2000.dp))
}
}
@Test
fun `the fixed scales get taller in listed order`() {
assertThat(TimelineScale.Compact.hourHeight(phoneViewport))
.isLessThan(TimelineScale.Regular.hourHeight(phoneViewport))
assertThat(TimelineScale.Regular.hourHeight(phoneViewport))
.isLessThan(TimelineScale.Comfortable.hourHeight(phoneViewport))
}
@Test
fun `fit-day puts all 24 hours inside a phone viewport`() {
// The whole point of the issue: no vertical scrolling to see the day.
val h = TimelineScale.FitDay.hourHeight(phoneViewport)
assertThat(h * 24).isAtMost(phoneViewport)
// …and it uses the space, rather than leaving most of it empty.
assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f)
}
@Test
fun `fit-day clamps instead of shrinking hours past legibility`() {
// A very short viewport (split screen, tiny device) would otherwise give
// hour rows too small for the gutter's 24 labels; the clamp wins and the
// timeline keeps a little scroll.
assertThat(TimelineScale.FitDay.hourHeight(120.dp)).isEqualTo(FIT_DAY_MIN)
}
@Test
fun `fit-day clamps instead of stretching hours on a very tall viewport`() {
assertThat(TimelineScale.FitDay.hourHeight(4000.dp)).isEqualTo(FIT_DAY_MAX)
}
@Test
fun `the minimum event height matches the old 24dp floor at the default scale`() {
// MIN_EVENT_FRACTION replaced a hardcoded 24dp; at Regular it must still
// land there, or short events change size for everyone who never touched
// the setting.
val floor = TimelineScale.Regular.hourHeight(phoneViewport) * MIN_EVENT_FRACTION
assertThat(floor.value).isWithin(0.5f).of(24f)
}
@Test
fun `the minimum event height stays a fixed share of an hour`() {
// A fixed dp floor would swallow ever more of the day as the scale drops;
// as a fraction it always means the same duration.
for (scale in TimelineScale.presets) {
val hour = scale.hourHeight(phoneViewport)
assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION)
}
}
@Test
fun `a pinched height is used as given`() {
assertThat(TimelineScale.custom(63.dp).hourHeight(phoneViewport)).isEqualTo(63.dp)
}
@Test
fun `a pinched height never leaves the timeline short of the screen`() {
// Zooming out means "show me more of the day"; once the whole day is on
// screen that is answered, and going further would only open dead space
// under midnight.
val tooSmall = TimelineScale.custom(4.dp)
assertThat(tooSmall.hourHeight(phoneViewport) * 24).isAtLeast(phoneViewport)
}
@Test
fun `a height pinched on one viewport still fills a taller one`() {
// Pinching all the way out in landscape stores a small height; rotating
// back to portrait must not leave the day floating in the top half.
val pinchedInLandscape = TimelineScale.custom(fillHourHeight(320.dp))
assertThat(pinchedInLandscape.hourHeight(phoneViewport) * 24).isAtLeast(phoneViewport)
}
@Test
fun `a pinch can zoom in past every preset`() {
// Otherwise the gesture would be strictly less capable than the picker
// it is meant to refine.
val presetHeights = TimelineScale.presets.map { it.hourHeight(phoneViewport) }
assertThat(MAX_PINCH_HOUR_HEIGHT).isGreaterThan(presetHeights.max())
assertThat(TimelineScale.custom(200.dp).hourHeight(phoneViewport)).isEqualTo(200.dp)
}
@Test
fun `every scale round-trips through storage`() {
for (scale in TimelineScale.presets + TimelineScale.custom(63.5f.dp)) {
assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale)
}
}
@Test
fun `scales stored before the pinch existed still read back`() {
// These were enum names once. An install that picked one must not be
// silently reset to the default by the sealed-type rewrite.
assertThat(parseTimelineScale("FitDay")).isEqualTo(TimelineScale.FitDay)
assertThat(parseTimelineScale("Compact")).isEqualTo(TimelineScale.Compact)
assertThat(parseTimelineScale("Regular")).isEqualTo(TimelineScale.Regular)
assertThat(parseTimelineScale("Comfortable")).isEqualTo(TimelineScale.Comfortable)
}
@Test
fun `an unreadable stored scale falls back to the default`() {
for (stored in listOf(null, "", "Roomy", "custom:", "custom:huge")) {
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
}
}
}

View File

@@ -0,0 +1,135 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The arithmetic behind the pinch's focal anchor (#56) — the one part of the
* gesture that is not a pointer-event concern and can be pinned down here.
*/
class TimelineZoomTest {
@Test
fun `the moment under the fingers stays under them`() {
// Scrolled to 09:00 at 56dp/h, pinching around a point 100px down the
// viewport — that point is 10:47-ish, and it has to still be there after.
val old = 56f
val new = 84f
val scroll = (9 * old).toInt()
val centroidY = 100f
val before = (scroll + centroidY) / old
val after = anchoredScroll(scroll, centroidY, old, new)
assertThat((after + centroidY) / new).isWithin(0.001f).of(before)
}
@Test
fun `zooming in scrolls down and zooming out scrolls back up`() {
val scroll = 500
val centroidY = 200f
assertThat(anchoredScroll(scroll, centroidY, 56f, 84f)).isGreaterThan(scroll.toFloat())
assertThat(anchoredScroll(scroll, centroidY, 56f, 32f)).isLessThan(scroll.toFloat())
}
@Test
fun `an unchanged scale asks for no scroll`() {
assertThat(anchoredScroll(500, 200f, 56f, 56f)).isWithin(0.001f).of(500f)
}
@Test
fun `pinching at the very top of a day held at the top keeps it there`() {
// Midnight is at offset 0 whatever the scale, so there is nothing to
// correct — a pinch here must not push the day off its own start.
assertThat(anchoredScroll(0, 0f, 56f, 84f)).isWithin(0.001f).of(0f)
}
@Test
fun `a pinch lands on whole pixels`() {
// The gutter is 24 stacked hour-tall boxes, each rounding its own height,
// while the lines and blocks are drawn at the fractional one — a
// fractional hour puts the two on different grids and jumps the labels
// about as the pinch drifts across each half pixel.
for (target in listOf(56.4f, 56.6f, 83.5f, 120.01f)) {
val landed = pinchedHourHeightPx(target, fillPx = 28f, maxPx = 240f)
assertThat(landed).isEqualTo(landed.toInt().toFloat())
}
}
@Test
fun `a pinch stops where the day fills the screen`() {
assertThat(pinchedHourHeightPx(target = 5f, fillPx = 28f, maxPx = 240f)).isEqualTo(28f)
}
@Test
fun `filling the screen outranks the ceiling`() {
// On a viewport tall enough for the two to disagree, dead space under
// midnight is the worse of the two failures.
assertThat(pinchedHourHeightPx(target = 10f, fillPx = 300f, maxPx = 240f)).isEqualTo(300f)
}
@Test
fun `a pinch stops at the ceiling`() {
assertThat(pinchedHourHeightPx(target = 9_000f, fillPx = 28f, maxPx = 240f)).isEqualTo(240f)
}
@Test
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 maxPx = 616.5f
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
assertThat(floor).isEqualTo(63f)
assertThat(ceiling).isEqualTo(616f)
// Landing there and being pushed further must not move them again.
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
assertThat(pinchedHourHeightPx(ceiling * 1.1f, fillPx, maxPx)).isEqualTo(ceiling)
}
@Test
fun `the fill floor never leaves dead space under midnight`() {
// Rounding the floor down would open a gap the pinch cannot close.
val viewport = 1490f
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
assertThat(floor * 24).isAtLeast(viewport)
}
@Test
fun `a settled pinch is what gets persisted`() {
var persisted: TimelineScale? = null
val zoom = TimelineZoom(TimelineScale.Regular) { persisted = it }
zoom.beginPinch()
zoom.pinchTo(70.dp)
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(70.dp))
// Nothing is written until the fingers lift — a DataStore write per
// pointer frame is what this state holder exists to avoid.
assertThat(persisted).isNull()
zoom.endPinch()
assertThat(persisted).isEqualTo(TimelineScale.custom(70.dp))
}
@Test
fun `the stored value cannot snap the timeline back mid-pinch`() {
val zoom = TimelineZoom(TimelineScale.Regular) {}
zoom.beginPinch()
zoom.pinchTo(70.dp)
// The preference echoing its old value back (it is a frame or two behind
// the fingers) must not land while the gesture is still running.
zoom.adopt(TimelineScale.Regular)
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(70.dp))
// Once it has settled, Settings can still move it.
zoom.endPinch()
zoom.adopt(TimelineScale.Compact)
assertThat(zoom.scale).isEqualTo(TimelineScale.Compact)
}
}