From 2f8943c4c61331d64bf824fa949c4f7b0ab43e30 Mon Sep 17 00:00:00 2001 From: zjywill Date: Tue, 11 Aug 2026 14:22:50 +0800 Subject: [PATCH] Add a Compose API, screenshot tests and CI Compose is the reason a new project would or would not pick this library up, and there was no CI to notice that master had been unbuildable since 1.6. Shared geometry: HiveGeometry now owns the comb layout and the fade wave as plain functions with no Android dependencies, and HiveProgressView delegates to it. Writing the trigonometry a second time for Compose would have let the two renderers drift apart; this way they cannot. overwatch-compose: a HiveProgress() composable drawing the same comb through Canvas and rememberInfiniteTransition. Corner rounding is built into the path with quadratics, since Compose applies a path effect only to strokes. There is also a stateless overload taking the wave position, which makes the output a pure function of its arguments -- that is what the screenshot tests pin, and it lets callers drive the wave themselves. Unlike the View, it reports itself as an indeterminate progress indicator to accessibility services. AGP 9 has built-in Kotlin support, so org.jetbrains.kotlin.android must not be applied; the Compose compiler plugin is pinned to 2.2.10 to match the kotlin-gradle-plugin AGP bundles. Tests: nine JVM tests over HiveGeometry, pinning the invariant the spacing parameter exists to provide -- all six neighbours of the centre cell stay equidistant -- plus that the comb never escapes its box, the radius never goes negative, and the wave order is a permutation. Six Roborazzi screenshots cover zero spacing, wide spacing, rounded corners, mid-wave and shrink. CI assembles debug and release, lints, runs both test suites, and rehearses the JitPack publish with -Pgroup/-Pversion so a broken release is caught before the tag is cut. Release builds are what caught a debug-only tooling dependency leaking into the demo's preview code. The demo is now Compose, edge-to-edge, and renders both implementations from one spacing slider so any divergence is visible side by side. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 81 +++++++ README.md | 28 +++ app/build.gradle.kts | 14 ++ .../java/com/comix/demo/MainActivity.java | 45 ---- .../main/java/com/comix/demo/MainActivity.kt | 104 +++++++++ app/src/main/res/layout/activity_main.xml | 44 ---- build.gradle.kts | 2 + gradle/libs.versions.toml | 28 +++ overwatch-compose/build.gradle.kts | 81 +++++++ overwatch-compose/proguard-rules.pro | 1 + .../comix/overwatch/compose/HiveProgress.kt | 220 ++++++++++++++++++ .../compose/HiveProgressScreenshotTest.kt | 104 +++++++++ .../src/test/screenshots/mid-wave.png | Bin 0 -> 8808 bytes .../screenshots/rainbow-fully-visible.png | Bin 0 -> 11378 bytes .../src/test/screenshots/rounded-corners.png | Bin 0 -> 14461 bytes .../src/test/screenshots/shrink.png | Bin 0 -> 7860 bytes .../src/test/screenshots/wide-spacing.png | Bin 0 -> 8186 bytes .../src/test/screenshots/zero-spacing.png | Bin 0 -> 7957 bytes overwatch/build.gradle.kts | 4 + .../com/comix/overwatch/HiveGeometry.java | 151 ++++++++++++ .../com/comix/overwatch/HiveProgressView.java | 112 ++------- .../com/comix/overwatch/HiveGeometryTest.java | 133 +++++++++++ settings.gradle.kts | 2 +- 23 files changed, 977 insertions(+), 177 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 app/src/main/java/com/comix/demo/MainActivity.java create mode 100644 app/src/main/java/com/comix/demo/MainActivity.kt delete mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 overwatch-compose/build.gradle.kts create mode 100644 overwatch-compose/proguard-rules.pro create mode 100644 overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt create mode 100644 overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt create mode 100644 overwatch-compose/src/test/screenshots/mid-wave.png create mode 100644 overwatch-compose/src/test/screenshots/rainbow-fully-visible.png create mode 100644 overwatch-compose/src/test/screenshots/rounded-corners.png create mode 100644 overwatch-compose/src/test/screenshots/shrink.png create mode 100644 overwatch-compose/src/test/screenshots/wide-spacing.png create mode 100644 overwatch-compose/src/test/screenshots/zero-spacing.png create mode 100644 overwatch/src/main/java/com/comix/overwatch/HiveGeometry.java create mode 100644 overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f481ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v5 + + # Catches the class of breakage that left master unbuildable between 1.6 and 2.0.0: + # assemble every module, then run lint and the unit tests. + - name: Assemble + run: ./gradlew assembleDebug assembleRelease --stacktrace + + - name: Lint + run: ./gradlew lint --stacktrace + + - name: Unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Screenshot tests + run: ./gradlew verifyRoborazziDebug --stacktrace + + # Roborazzi goldens are recorded on the maintainer's machine; if the Linux renderer + # disagrees, the diffs are the fastest way to see whether it is a real regression. + - name: Upload screenshot diffs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: roborazzi-diffs + path: | + **/build/outputs/roborazzi/** + if-no-files-found: ignore + retention-days: 7 + + - name: Upload lint reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lint-reports + path: '**/build/reports/lint-results-*.html' + if-no-files-found: ignore + retention-days: 7 + + publish-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v5 + + # JitPack builds tags with -Pgroup/-Pversion injected. Exercising the same path here means a + # broken release is caught before the tag is cut, not after. + - name: Publish to Maven local as JitPack would + run: | + ./gradlew publishToMavenLocal \ + -Pgroup=com.github.zjywill -Pversion=0.0.0-ci --stacktrace + + - name: Show published artifacts + run: find ~/.m2/repository/com/github/zjywill -type f | sort diff --git a/README.md b/README.md index ce62672..95bc0a3 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,37 @@ dependencies { } ``` +Check the [JitPack page](https://jitpack.io/#zjywill/OverwatchProgress) for the coordinates of the +newest release: `2.1.0` adds a second artifact for the Compose API, and JitPack names artifacts +differently once a repository publishes more than one module. + > The coordinate is unchanged from `1.6`, so upgrading is a version bump. `2.0.0` requires JDK 17 > and raises `minSdk` to 24. +# Compose + +```kotlin +HiveProgress( + modifier = Modifier.size(160.dp), + colors = HiveProgressDefaults.Rainbow, + spacing = 8.dp, + cornerRadius = 6.dp, + shrink = true, +) +``` + +There is also a stateless overload that takes the wave position instead of animating itself, which +is what makes the comb a pure function of its arguments — useful for screenshot tests, or for +driving the wave from your own animation: + +```kotlin +HiveProgress(progress = 420f, modifier = Modifier.size(160.dp)) +``` + +Both renderers share [`HiveGeometry`](overwatch/src/main/java/com/comix/overwatch/HiveGeometry.java), +so the comb layout and the fade wave cannot drift apart between them. The Compose version reports +itself as an indeterminate progress indicator to accessibility services. + # Spacing `hive_spacing` is the gap between two neighbouring hexagons, and it applies uniformly in all six diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f803911..0f1eb13 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) } android { @@ -14,6 +15,10 @@ android { versionName = "1.0" } + buildFeatures { + compose = true + } + buildTypes { release { isMinifyEnabled = false @@ -29,4 +34,13 @@ android { dependencies { implementation(project(":overwatch")) + implementation(project(":overwatch-compose")) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.activity.compose) + debugImplementation(libs.compose.ui.tooling) } diff --git a/app/src/main/java/com/comix/demo/MainActivity.java b/app/src/main/java/com/comix/demo/MainActivity.java deleted file mode 100644 index 3635e74..0000000 --- a/app/src/main/java/com/comix/demo/MainActivity.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.comix.demo; - -import android.app.Activity; -import android.os.Bundle; -import android.widget.SeekBar; -import android.widget.TextView; - -import com.comix.overwatch.HiveProgressView; - -import java.util.Locale; - -public class MainActivity extends Activity { - - private static final int INITIAL_SPACING_DP = 8; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - final HiveProgressView progressView = findViewById(R.id.hive_progress); - final TextView label = findViewById(R.id.spacing_label); - SeekBar bar = findViewById(R.id.spacing_bar); - - bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - progressView.setSpacingDp(progress); - label.setText(String.format(Locale.US, "Spacing %ddp", progress)); - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - } - }); - - bar.setProgress(INITIAL_SPACING_DP); - progressView.setSpacingDp(INITIAL_SPACING_DP); - label.setText(String.format(Locale.US, "Spacing %ddp", INITIAL_SPACING_DP)); - } -} diff --git a/app/src/main/java/com/comix/demo/MainActivity.kt b/app/src/main/java/com/comix/demo/MainActivity.kt new file mode 100644 index 0000000..2bdef7d --- /dev/null +++ b/app/src/main/java/com/comix/demo/MainActivity.kt @@ -0,0 +1,104 @@ +package com.comix.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.comix.overwatch.HiveProgressView +import com.comix.overwatch.compose.HiveProgress +import com.comix.overwatch.compose.HiveProgressDefaults +import kotlin.math.roundToInt + +private const val ANIMATION_MILLIS = 5000 +private val CORNER_RADIUS = 8.dp +private const val MAX_SPACING_DP = 48f + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface(Modifier.fillMaxSize()) { DemoScreen() } + } + } + } +} + +/** Drives both renderers from one slider, so any drift between them is visible side by side. */ +@Composable +private fun DemoScreen() { + var spacingDp by remember { mutableFloatStateOf(8f) } + // HiveProgressView still takes a raw pixel corner radius, so convert to keep both sides equal. + val cornerRadiusPx = with(LocalDensity.current) { CORNER_RADIUS.roundToPx() } + + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + ) { + Text("Compose", style = MaterialTheme.typography.labelLarge) + HiveProgress( + modifier = Modifier.size(170.dp), + colors = HiveProgressDefaults.Rainbow, + spacing = spacingDp.dp, + cornerRadius = CORNER_RADIUS, + shrink = true, + maxAlpha = 1f, + animationDurationMillis = ANIMATION_MILLIS, + ) + + Text("View", style = MaterialTheme.typography.labelLarge) + AndroidView( + factory = { context -> + HiveProgressView(context).apply { + isRainbow = true + isShrink = true + cornerRadius = cornerRadiusPx + maxAlpha = 255 + animationTime = ANIMATION_MILLIS + } + }, + update = { view -> view.setSpacingDp(spacingDp) }, + modifier = Modifier.size(170.dp), + ) + + Text("Spacing ${spacingDp.roundToInt()}dp") + Slider( + value = spacingDp, + onValueChange = { spacingDp = it }, + valueRange = 0f..MAX_SPACING_DP, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DemoScreenPreview() { + MaterialTheme { DemoScreen() } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index bc5a308..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - diff --git a/build.gradle.kts b/build.gradle.kts index 549000a..0244cef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.roborazzi) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 504a686..9f05b71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,37 @@ [versions] agp = "9.3.1" +# Must match the kotlin-gradle-plugin AGP bundles (9.3.1 -> 2.2.10). +kotlin = "2.2.10" +composeBom = "2026.06.01" +activityCompose = "1.13.0" compileSdk = "36" targetSdk = "36" minSdk = "24" +junit = "4.13.2" +robolectric = "4.16.1" +androidxTestJunit = "1.3.0" +roborazzi = "1.71.0" + +[libraries] +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } +roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/overwatch-compose/build.gradle.kts b/overwatch-compose/build.gradle.kts new file mode 100644 index 0000000..063dd38 --- /dev/null +++ b/overwatch-compose/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.roborazzi) + `maven-publish` +} + +android { + namespace = "com.comix.overwatch.compose" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildFeatures { + compose = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +// JitPack injects -Pgroup and -Pversion; these are only the local fallbacks. +if (group.toString().isBlank() || group.toString() == rootProject.name) { + group = "com.github.zjywill" +} +if (version.toString() == Project.DEFAULT_VERSION) { + version = "2.1.0" +} + +publishing { + publications { + register("release") { + afterEvaluate { from(components["release"]) } + } + } +} + +dependencies { + // api: HiveGeometry is part of this module's surface, and callers may want the View too. + api(project(":overwatch")) + + implementation(platform(libs.compose.bom)) + api(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.junit) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.ui.test.junit4) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/overwatch-compose/proguard-rules.pro b/overwatch-compose/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/overwatch-compose/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt b/overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt new file mode 100644 index 0000000..ff57a19 --- /dev/null +++ b/overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt @@ -0,0 +1,220 @@ +package com.comix.overwatch.compose + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.comix.overwatch.HiveGeometry +import kotlin.math.hypot +import kotlin.math.min + +/** Defaults shared by the [HiveProgress] overloads. */ +object HiveProgressDefaults { + + /** Size the indicator falls back to when the caller does not constrain it. */ + val Size: Dp = 96.dp + + /** Matches the View's default of 70 out of 255. */ + const val MaxAlpha: Float = 70f / 255f + + const val AnimationDurationMillis: Int = 2000 + + /** The seven-colour ramp used by the View's `hive_rainbow` mode. */ + val Rainbow: List = listOf( + Color(0xFFFF0000), + Color(0xFFFF7F00), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF0000FF), + Color(0xFF4B0082), + Color(0xFF9400D3), + ) +} + +/** + * An indeterminate loading indicator: seven hexagons in a 2-3-2 comb that fade in and out as a + * wave. + * + * The comb is laid out by [HiveGeometry], the same code the `HiveProgressView` uses, so both + * renderers stay in step. + * + * @param color colour of every hexagon; ignored when [colors] is supplied + * @param colors per-cell colour ramp, e.g. [HiveProgressDefaults.Rainbow] + * @param spacing gap between neighbouring hexagons; [Dp.Unspecified] derives it from the size + * @param cornerRadius how far each hexagon corner is rounded + * @param shrink scale each hexagon with its opacity instead of only fading it + * @param maxAlpha peak opacity of a hexagon, in `[0, 1]` + */ +@Composable +fun HiveProgress( + modifier: Modifier = Modifier, + color: Color = Color.Black, + colors: List? = null, + spacing: Dp = Dp.Unspecified, + cornerRadius: Dp = 0.dp, + shrink: Boolean = false, + maxAlpha: Float = HiveProgressDefaults.MaxAlpha, + animationDurationMillis: Int = HiveProgressDefaults.AnimationDurationMillis, +) { + val transition = rememberInfiniteTransition(label = "HiveProgress") + val progress by transition.animateFloat( + initialValue = 0f, + targetValue = HiveGeometry.MAX_PROGRESS.toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = animationDurationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "progress", + ) + HiveProgress( + progress = progress, + modifier = modifier.progressSemantics(), + color = color, + colors = colors, + spacing = spacing, + cornerRadius = cornerRadius, + shrink = shrink, + maxAlpha = maxAlpha, + ) +} + +/** + * The stateless form of [HiveProgress]: the caller drives the wave, which makes the output a pure + * function of [progress] and so keeps screenshot tests deterministic. + * + * @param progress position in the wave, from 0 to [HiveGeometry.MAX_PROGRESS] + */ +@Composable +fun HiveProgress( + progress: Float, + modifier: Modifier = Modifier, + color: Color = Color.Black, + colors: List? = null, + spacing: Dp = Dp.Unspecified, + cornerRadius: Dp = 0.dp, + shrink: Boolean = false, + maxAlpha: Float = HiveProgressDefaults.MaxAlpha, +) { + val path = remember { Path() } + val vertices = remember { FloatArray(12) } + + Canvas(modifier.defaultMinSize(HiveProgressDefaults.Size, HiveProgressDefaults.Size)) { + val side = min(size.width, size.height) + if (side <= 0f) return@Canvas + + val gap = if (spacing == Dp.Unspecified) HiveGeometry.autoSpacing(side, side) else spacing.toPx() + val radius = HiveGeometry.radiusFor(side, side, gap) + if (radius <= 0f) return@Canvas + + val originX = size.width / 2f + val originY = size.height / 2f + val corner = cornerRadius.toPx() + + for (i in 0 until HiveGeometry.HEX_COUNT) { + val wave = HiveGeometry.waveOrder(i) + val fraction = HiveGeometry.alphaFractionAt(wave, progress) + if (fraction <= 0f) continue + + path.buildHexagon( + cx = HiveGeometry.cellCenterX(i, originX, radius, gap), + cy = HiveGeometry.cellCenterY(i, originY, radius, gap), + r = if (shrink) radius * fraction else radius, + corner = corner, + vertices = vertices, + ) + drawPath( + path = path, + color = colors?.get((wave - 1) % colors.size) ?: color, + alpha = fraction * maxAlpha, + ) + } + } +} + +/** + * Rewrites [this] as a pointy-top hexagon with rounded corners. Each corner is trimmed back along + * both adjacent edges and bridged with a quadratic through the original vertex, which is what + * `CornerPathEffect` does for the View. + */ +private fun Path.buildHexagon( + cx: Float, + cy: Float, + r: Float, + corner: Float, + vertices: FloatArray, +) { + HiveGeometry.hexagonVertices(cx, cy, r, vertices) + reset() + + if (corner <= 0f) { + moveTo(vertices[0], vertices[1]) + for (i in 1 until 6) { + lineTo(vertices[i * 2], vertices[i * 2 + 1]) + } + close() + return + } + + for (i in 0 until 6) { + val vx = vertices[i * 2] + val vy = vertices[i * 2 + 1] + val px = vertices[(i + 5) % 6 * 2] + val py = vertices[(i + 5) % 6 * 2 + 1] + val nx = vertices[(i + 1) % 6 * 2] + val ny = vertices[(i + 1) % 6 * 2 + 1] + + val inLength = hypot(px - vx, py - vy) + val outLength = hypot(nx - vx, ny - vy) + if (inLength <= 0f || outLength <= 0f) continue + + val inT = min(corner, inLength / 2f) / inLength + val outT = min(corner, outLength / 2f) / outLength + + val startX = vx + (px - vx) * inT + val startY = vy + (py - vy) * inT + val endX = vx + (nx - vx) * outT + val endY = vy + (ny - vy) * outT + + if (i == 0) moveTo(startX, startY) else lineTo(startX, startY) + quadraticTo(vx, vy, endX, endY) + } + close() +} + +@Preview(name = "Rainbow", showBackground = true) +@Composable +private fun HiveProgressRainbowPreview() { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(160.dp), + colors = HiveProgressDefaults.Rainbow, + cornerRadius = 6.dp, + maxAlpha = 1f, + ) +} + +@Preview(name = "Wide spacing", showBackground = true) +@Composable +private fun HiveProgressSpacingPreview() { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(160.dp), + spacing = 16.dp, + maxAlpha = 1f, + ) +} diff --git a/overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt b/overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt new file mode 100644 index 0000000..e265883 --- /dev/null +++ b/overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt @@ -0,0 +1,104 @@ +package com.comix.overwatch.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.comix.overwatch.HiveGeometry +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Renders the comb at fixed points in the wave. Every case uses the stateless [HiveProgress] + * overload, so the output depends only on its arguments and never on wall-clock timing. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w220dp-h220dp-xhdpi") +class HiveProgressScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun rainbowFullyVisible() = capture("rainbow-fully-visible") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + colors = HiveProgressDefaults.Rainbow, + maxAlpha = 1f, + ) + } + + /** At zero spacing the hexagons must tile with no seam and no overlap. */ + @Test + fun zeroSpacingTiles() = capture("zero-spacing") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + spacing = 0.dp, + color = Color.Black, + maxAlpha = 1f, + ) + } + + @Test + fun wideSpacingShrinksHexagons() = capture("wide-spacing") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + spacing = 24.dp, + color = Color.Black, + maxAlpha = 1f, + ) + } + + @Test + fun roundedCorners() = capture("rounded-corners") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + cornerRadius = 10.dp, + color = Color.Black, + maxAlpha = 1f, + ) + } + + /** Mid-wave: the staggered fade should leave the cells at visibly different opacities. */ + @Test + fun midWave() = capture("mid-wave") { + HiveProgress( + progress = 420f, + modifier = Modifier.size(200.dp), + colors = HiveProgressDefaults.Rainbow, + maxAlpha = 1f, + ) + } + + @Test + fun shrinkScalesWithOpacity() = capture("shrink") { + HiveProgress( + progress = 420f, + modifier = Modifier.size(200.dp), + shrink = true, + colors = HiveProgressDefaults.Rainbow, + maxAlpha = 1f, + ) + } + + private fun capture(name: String, content: @Composable () -> Unit) { + compose.setContent { + androidx.compose.foundation.layout.Box(Modifier.background(Color.White)) { content() } + } + compose.onRoot().captureRoboImage("src/test/screenshots/$name.png") + } +} diff --git a/overwatch-compose/src/test/screenshots/mid-wave.png b/overwatch-compose/src/test/screenshots/mid-wave.png new file mode 100644 index 0000000000000000000000000000000000000000..4421df00b5c38f0776decd785db3cd3ceea15c07 GIT binary patch literal 8808 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 KY?+Mti~j|Kd;lB( literal 0 HcmV?d00001 diff --git a/overwatch-compose/src/test/screenshots/shrink.png b/overwatch-compose/src/test/screenshots/shrink.png new file mode 100644 index 0000000000000000000000000000000000000000..cbcb066d3953b0a56005bddcc53d02319e245423 GIT binary patch literal 7860 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 Gzw&S3=PG0X literal 0 HcmV?d00001 diff --git a/overwatch-compose/src/test/screenshots/wide-spacing.png b/overwatch-compose/src/test/screenshots/wide-spacing.png new file mode 100644 index 0000000000000000000000000000000000000000..310a74b948214aef8b8760031881fde029f7a033 GIT binary patch literal 8186 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 hold relative comb coordinates. A gap propagates through the horizontal pitch + * and the row step in a way that keeps all six neighbour gaps equal: for a comb of circumradius + * {@code r} with gap {@code g}, every neighbour centre sits {@code √3·r + g} away. + */ +public final class HiveGeometry { + + /** Number of hexagons in the comb. */ + public static final int HEX_COUNT = 7; + + public static final float SQRT_3 = (float) Math.sqrt(3); + + /** Progress value at which one full fade-in/fade-out cycle completes. */ + public static final int MAX_PROGRESS = 1450; + + /** + * Fraction of the content box used as the gap when no explicit spacing is supplied. Chosen to + * reproduce the gap this comb had before spacing became configurable. + */ + public static final float AUTO_SPACING_RATIO = 1f / 23f; + + /** Progress value at which every hexagon is fully faded in. */ + public static final float FULLY_VISIBLE_PROGRESS = 700f; + + private static final int FADE_OUT_START = 700; + private static final int FULLY_FADED = 1400; + + /** Column offset of each cell, counted in half a horizontal pitch. */ + private static final int[] CELL_HALF_COLUMN = {-1, 1, -2, 0, 2, -1, 1}; + + /** Row index of each cell, 0 (top) to 2 (bottom). */ + private static final int[] CELL_ROW = {0, 0, 1, 1, 1, 2, 2}; + + /** Position of each cell in the fade wave, preserving the original Overwatch ordering. */ + private static final int[] CELL_WAVE_ORDER = {1, 2, 6, 7, 3, 5, 4}; + + /** Per-cell tilt applied in image mode, in degrees. */ + private static final float[] CELL_IMAGE_TILT = {10, -10, -10, 5, -10, 20, 10}; + + private HiveGeometry() { + } + + /** Size-proportional gap used when the caller does not pin one. */ + public static float autoSpacing(float contentWidth, float contentHeight) { + return Math.min(contentWidth, contentHeight) * AUTO_SPACING_RATIO; + } + + /** + * Solves the hexagon circumradius from the space left over once the gaps are taken out. The comb + * occupies {@code 3√3·r + 2g} horizontally and {@code 5r + √3·g} vertically, so the radius is + * whichever of those two constraints binds first. + */ + public static float radiusFor(float contentWidth, float contentHeight, float gap) { + float fromWidth = (contentWidth - 2 * gap) / (3 * SQRT_3); + float fromHeight = (contentHeight - SQRT_3 * gap) / 5f; + return Math.max(0f, Math.min(fromWidth, fromHeight)); + } + + /** Centre-to-centre distance between two hexagons in the same row. */ + public static float pitch(float radius, float gap) { + return SQRT_3 * radius + gap; + } + + /** Vertical distance between two adjacent rows. */ + public static float rowStep(float radius, float gap) { + return 1.5f * radius + SQRT_3 * gap / 2f; + } + + /** Half the width of a pointy-top hexagon, i.e. the distance from centre to a vertical edge. */ + public static float halfWidth(float radius) { + return radius * SQRT_3 / 2f; + } + + public static float cellCenterX(int index, float originX, float radius, float gap) { + return originX + CELL_HALF_COLUMN[index] * pitch(radius, gap) / 2f; + } + + public static float cellCenterY(int index, float originY, float radius, float gap) { + return originY + (CELL_ROW[index] - 1) * rowStep(radius, gap); + } + + public static int waveOrder(int index) { + return CELL_WAVE_ORDER[index]; + } + + public static float imageTilt(int index) { + return CELL_IMAGE_TILT[index]; + } + + /** + * Opacity of a cell at a point in the wave, as a fraction in {@code [0, 1]}. Each cell fades in + * 100 progress units after the previous one, then the whole comb fades out in the same order. + * + * @param wave the cell's position in the wave, from {@link #waveOrder(int)} + */ + public static float alphaFractionAt(int wave, float progress) { + float fraction; + if (progress > wave * 100) { + fraction = 1f; + } else { + int min = (wave - 1) * 100; + fraction = Math.max(0f, progress - min) / 100f; + } + if (progress > FADE_OUT_START) { + float fadeProgress = progress - FADE_OUT_START; + if (fadeProgress > wave * 100) { + fraction = 0f; + } else { + int min = (wave - 1) * 100; + fraction = 1f - Math.max(0f, fadeProgress - min) / 100f; + } + } + if (progress > FULLY_FADED) { + fraction = 0f; + } + return fraction; + } + + /** Opacity of a cell scaled into the 0-255 range a {@code Paint} expects. */ + public static int alphaAt(int wave, float progress, int maxAlpha) { + return (int) (alphaFractionAt(wave, progress) * maxAlpha); + } + + /** + * Writes the six vertices of a pointy-top hexagon into {@code out}, as x,y pairs starting from + * the top vertex and running clockwise. + * + * @param out an array of at least 12 floats, overwritten in place + */ + public static void hexagonVertices(float cx, float cy, float r, float[] out) { + float edge = halfWidth(r); + out[0] = cx; + out[1] = cy - r; + out[2] = cx + edge; + out[3] = cy - r / 2f; + out[4] = cx + edge; + out[5] = cy + r / 2f; + out[6] = cx; + out[7] = cy + r; + out[8] = cx - edge; + out[9] = cy + r / 2f; + out[10] = cx - edge; + out[11] = cy - r / 2f; + } +} diff --git a/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java b/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java index dbd8bcc..5a3b9e8 100644 --- a/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java +++ b/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java @@ -16,48 +16,19 @@ public class HiveProgressView extends View { - /** Number of hexagons in the 2-3-2 comb. */ - private static final int HEX_COUNT = 7; - - private static final float SQRT_3 = (float) Math.sqrt(3); - private static final int[] RAINBOW_COLOR = { 0xFFFF0000, 0xFFFF7F00, 0xFFFFFF00, 0xFF00FF00, 0xFF0000FF, 0xFF4B0082, 0xFF9400D3 }; - /** - * Cells are stored as top-left, top-right, mid-left, mid-centre, mid-right, bottom-left, - * bottom-right. Column offsets are counted in half a horizontal pitch so the staggered - * rows stay integral. - */ - private static final int[] CELL_HALF_COLUMN = {-1, 1, -2, 0, 2, -1, 1}; - - private static final int[] CELL_ROW = {0, 0, 1, 1, 1, 2, 2}; - - /** Position of each cell in the fade wave, preserving the original Overwatch ordering. */ - private static final int[] CELL_WAVE_ORDER = {1, 2, 6, 7, 3, 5, 4}; - - /** Per-cell tilt applied in image mode, in degrees. */ - private static final float[] CELL_IMAGE_TILT = {10, -10, -10, 5, -10, 20, 10}; - - private static final int MAX_PROGRESS_VALUE = 1450; private static final int PROGRESS_TIME = 2000; private static final int MAX_ALPHA = 70; private static final int DEFAULT_SIZE_DP = 96; - /** - * Fraction of the content box used as the gap when {@link #hive_spacing} is not supplied. Chosen - * to reproduce the gap this view had before spacing became configurable. - */ - private static final float AUTO_SPACING_RATIO = 1f / 23f; - - /** Progress value at which every hexagon is fully faded in; used for layout previews. */ - private static final float PREVIEW_PROGRESS = 700f; - private final Paint paint = new Paint(); - private final Path[] hexPaths = new Path[HEX_COUNT]; - private final float[] cellX = new float[HEX_COUNT]; - private final float[] cellY = new float[HEX_COUNT]; + private final Path[] hexPaths = new Path[HiveGeometry.HEX_COUNT]; + private final float[] cellX = new float[HiveGeometry.HEX_COUNT]; + private final float[] cellY = new float[HiveGeometry.HEX_COUNT]; + private final float[] vertices = new float[12]; private float hexRadius; private float actualProgress = 0; @@ -85,14 +56,14 @@ public HiveProgressView(Context context, AttributeSet attrs) { public HiveProgressView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); - for (int i = 0; i < HEX_COUNT; i++) { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { hexPaths[i] = new Path(); } initAttributes(attrs, defStyle); initPaint(); initImage(); if (isInEditMode()) { - actualProgress = PREVIEW_PROGRESS; + actualProgress = HiveGeometry.FULLY_VISIBLE_PROGRESS; } } @@ -242,7 +213,7 @@ public void setVisibility(int visibility) { private void startAnimation() { if (isInEditMode()) { - actualProgress = PREVIEW_PROGRESS; + actualProgress = HiveGeometry.FULLY_VISIBLE_PROGRESS; invalidate(); return; } @@ -259,7 +230,7 @@ private void stopAnimation() { private void resetAnimator() { stopAnimation(); - ValueAnimator animator = ValueAnimator.ofFloat(0, MAX_PROGRESS_VALUE); + ValueAnimator animator = ValueAnimator.ofFloat(0, HiveGeometry.MAX_PROGRESS); animator.setDuration(animationTime); animator.setInterpolator(new LinearInterpolator()); // Looping via the animator itself rather than restarting from onAnimationEnd: the old @@ -317,20 +288,14 @@ private void updateGeometry(int viewWidth, int viewHeight) { return; } - float gap = spacing >= 0 ? spacing - : Math.min(contentWidth, contentHeight) * AUTO_SPACING_RATIO; - float radiusFromWidth = (contentWidth - 2 * gap) / (3 * SQRT_3); - float radiusFromHeight = (contentHeight - SQRT_3 * gap) / 5f; - hexRadius = Math.max(0f, Math.min(radiusFromWidth, radiusFromHeight)); + float gap = spacing >= 0 ? spacing : HiveGeometry.autoSpacing(contentWidth, contentHeight); + hexRadius = HiveGeometry.radiusFor(contentWidth, contentHeight, gap); - float pitch = SQRT_3 * hexRadius + gap; - float rowStep = 1.5f * hexRadius + SQRT_3 * gap / 2f; float originX = getPaddingLeft() + contentWidth / 2f; float originY = getPaddingTop() + contentHeight / 2f; - - for (int i = 0; i < HEX_COUNT; i++) { - cellX[i] = originX + CELL_HALF_COLUMN[i] * pitch / 2f; - cellY[i] = originY + (CELL_ROW[i] - 1) * rowStep; + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + cellX[i] = HiveGeometry.cellCenterX(i, originX, hexRadius, gap); + cellY[i] = HiveGeometry.cellCenterY(i, originY, hexRadius, gap); } } @@ -347,9 +312,9 @@ protected void onDraw(Canvas canvas) { } private void drawHexagons(Canvas canvas) { - for (int i = 0; i < HEX_COUNT; i++) { - int wave = CELL_WAVE_ORDER[i]; - int alpha = getCellAlpha(wave, actualProgress); + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + int alpha = HiveGeometry.alphaAt(wave, actualProgress, maxAlpha); if (alpha <= 0) { continue; } @@ -362,14 +327,14 @@ private void drawHexagons(Canvas canvas) { } private void drawImages(Canvas canvas) { - float halfWidth = hexRadius * SQRT_3 / 2f; - for (int i = 0; i < HEX_COUNT; i++) { - int alpha = getCellAlpha(CELL_WAVE_ORDER[i], actualProgress); + float halfWidth = HiveGeometry.halfWidth(hexRadius); + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int alpha = HiveGeometry.alphaAt(HiveGeometry.waveOrder(i), actualProgress, maxAlpha); if (alpha <= 0) { continue; } canvas.save(); - canvas.rotate(CELL_IMAGE_TILT[i], cellX[i], cellY[i]); + canvas.rotate(HiveGeometry.imageTilt(i), cellX[i], cellY[i]); imageRes.setBounds(Math.round(cellX[i] - halfWidth), Math.round(cellY[i] - hexRadius), Math.round(cellX[i] + halfWidth), Math.round(cellY[i] + hexRadius)); imageRes.setAlpha(alpha); @@ -386,41 +351,14 @@ private int getHexagonColor(int wave) { } } - private int getCellAlpha(int num, float progress) { - float alpha; - if (progress > num * 100) { - alpha = maxAlpha; - } else { - int min = (num - 1) * 100; - alpha = (progress - min) > 0 ? progress - min : 0; - alpha = alpha * maxAlpha / 100; - } - if (progress > 700) { - float fadeProgress = progress - 700; - if (fadeProgress > num * 100) { - alpha = 0; - } else { - int min = (num - 1) * 100; - alpha = (fadeProgress - min) > 0 ? fadeProgress - min : 0; - alpha = maxAlpha - alpha * maxAlpha / 100; - } - } - if (progress > 1400) { - alpha = 0; - } - return (int) alpha; - } - /** Builds a pointy-top hexagon of circumradius {@code r} centred on ({@code cx}, {@code cy}). */ private void buildHexPath(Path path, float cx, float cy, float r) { - float edge = r * SQRT_3 / 2f; + HiveGeometry.hexagonVertices(cx, cy, r, vertices); path.reset(); - path.moveTo(cx, cy - r); - path.lineTo(cx + edge, cy - r / 2f); - path.lineTo(cx + edge, cy + r / 2f); - path.lineTo(cx, cy + r); - path.lineTo(cx - edge, cy + r / 2f); - path.lineTo(cx - edge, cy - r / 2f); + path.moveTo(vertices[0], vertices[1]); + for (int v = 2; v < vertices.length; v += 2) { + path.lineTo(vertices[v], vertices[v + 1]); + } path.close(); } } diff --git a/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java b/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java new file mode 100644 index 0000000..bc2b323 --- /dev/null +++ b/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java @@ -0,0 +1,133 @@ +package com.comix.overwatch; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class HiveGeometryTest { + + private static final float EPSILON = 0.01f; + + /** Index of the cell in the middle of the comb, which borders all six others. */ + private static final int CENTRE_CELL = 3; + + /** + * The property the spacing parameter is supposed to guarantee: one scalar gap widens the comb by + * the same amount in every direction. Without this, spacing would only look right along one axis. + */ + @Test + public void everyNeighbourOfTheCentreCellIsEquidistant() { + float box = 480f; + float gap = 12f; + float radius = HiveGeometry.radiusFor(box, box, gap); + float centreX = HiveGeometry.cellCenterX(CENTRE_CELL, box / 2f, radius, gap); + float centreY = HiveGeometry.cellCenterY(CENTRE_CELL, box / 2f, radius, gap); + float expected = HiveGeometry.SQRT_3 * radius + gap; + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + if (i == CENTRE_CELL) { + continue; + } + float dx = HiveGeometry.cellCenterX(i, box / 2f, radius, gap) - centreX; + float dy = HiveGeometry.cellCenterY(i, box / 2f, radius, gap) - centreY; + assertEquals("cell " + i, expected, (float) Math.hypot(dx, dy), EPSILON); + } + } + + /** At zero gap the hexagons must tile exactly: touching, with no overlap. */ + @Test + public void zeroGapTilesExactly() { + float box = 300f; + float radius = HiveGeometry.radiusFor(box, box, 0f); + float centreX = HiveGeometry.cellCenterX(CENTRE_CELL, box / 2f, radius, 0f); + float centreY = HiveGeometry.cellCenterY(CENTRE_CELL, box / 2f, radius, 0f); + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + if (i == CENTRE_CELL) { + continue; + } + float dx = HiveGeometry.cellCenterX(i, box / 2f, radius, 0f) - centreX; + float dy = HiveGeometry.cellCenterY(i, box / 2f, radius, 0f) - centreY; + assertEquals(HiveGeometry.SQRT_3 * radius, (float) Math.hypot(dx, dy), EPSILON); + } + } + + /** The radius is solved so that the comb always fits, whatever the gap. */ + @Test + public void combStaysInsideTheContentBox() { + for (float gap : new float[] {0f, 8f, 40f, 90f}) { + float width = 300f; + float height = 260f; + float radius = HiveGeometry.radiusFor(width, height, gap); + float halfWidth = HiveGeometry.halfWidth(radius); + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + float x = HiveGeometry.cellCenterX(i, width / 2f, radius, gap); + float y = HiveGeometry.cellCenterY(i, height / 2f, radius, gap); + String cell = "gap " + gap + ", cell " + i; + assertTrue(cell, x - halfWidth >= -EPSILON); + assertTrue(cell, x + halfWidth <= width + EPSILON); + assertTrue(cell, y - radius >= -EPSILON); + assertTrue(cell, y + radius <= height + EPSILON); + } + } + } + + @Test + public void radiusCollapsesToZeroRatherThanGoingNegative() { + assertEquals(0f, HiveGeometry.radiusFor(100f, 100f, 500f), EPSILON); + assertEquals(0f, HiveGeometry.radiusFor(0f, 0f, 0f), EPSILON); + } + + @Test + public void cellsFadeInOneAfterAnother() { + assertEquals(0f, HiveGeometry.alphaFractionAt(1, 0f), EPSILON); + assertEquals(1f, HiveGeometry.alphaFractionAt(1, 100f), EPSILON); + // The last cell in the wave is still dark when the first one is already lit. + assertEquals(0f, HiveGeometry.alphaFractionAt(7, 100f), EPSILON); + assertEquals(1f, HiveGeometry.alphaFractionAt(7, HiveGeometry.FULLY_VISIBLE_PROGRESS), EPSILON); + } + + @Test + public void everyCellIsDarkAtBothEndsOfTheCycle() { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + assertEquals("cell " + i, 0f, HiveGeometry.alphaFractionAt(wave, 0f), EPSILON); + assertEquals("cell " + i, 0f, + HiveGeometry.alphaFractionAt(wave, HiveGeometry.MAX_PROGRESS), EPSILON); + } + } + + @Test + public void alphaStaysWithinRangeAcrossTheWholeCycle() { + for (float progress = 0f; progress <= HiveGeometry.MAX_PROGRESS; progress += 7f) { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + float fraction = HiveGeometry.alphaFractionAt(HiveGeometry.waveOrder(i), progress); + assertTrue("progress " + progress, fraction >= 0f && fraction <= 1f); + } + } + } + + /** Every cell appears exactly once in the wave, so no hexagon is skipped or doubled up. */ + @Test + public void waveOrderIsAPermutation() { + boolean[] seen = new boolean[HiveGeometry.HEX_COUNT + 1]; + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + assertTrue("wave " + wave + " out of range", wave >= 1 && wave <= HiveGeometry.HEX_COUNT); + assertTrue("wave " + wave + " repeated", !seen[wave]); + seen[wave] = true; + } + } + + @Test + public void hexagonVerticesLieOnTheCircumcircle() { + float[] vertices = new float[12]; + HiveGeometry.hexagonVertices(50f, 60f, 20f, vertices); + for (int v = 0; v < vertices.length; v += 2) { + double distance = Math.hypot(vertices[v] - 50f, vertices[v + 1] - 60f); + assertEquals(20f, (float) distance, EPSILON); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2183dfe..b9d3bf1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,4 +22,4 @@ dependencyResolutionManagement { rootProject.name = "OverwatchProgress" -include(":app", ":overwatch") +include(":app", ":overwatch", ":overwatch-compose")