├── .idea
├── .name
├── .gitignore
├── compiler.xml
├── kotlinc.xml
├── AndroidProjectSystem.xml
├── deploymentTargetSelector.xml
├── misc.xml
├── runConfigurations.xml
└── inspectionProfiles
│ └── Project_Default.xml
├── combo-breaker
├── .gitignore
├── src
│ └── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── dev
│ │ └── romainguy
│ │ └── text
│ │ └── combobreaker
│ │ ├── MeasuredText.kt
│ │ ├── Locale.kt
│ │ ├── Utilities.kt
│ │ ├── FlowShape.kt
│ │ ├── Geometry.kt
│ │ ├── Clipping.kt
│ │ ├── IntervalTree.kt
│ │ ├── TextPaint.kt
│ │ ├── FlowSlots.kt
│ │ ├── TextLayout.kt
│ │ └── BasicTextFlow.kt
├── gradle.properties
└── build.gradle
├── combo-breaker-demo
├── .gitignore
├── src
│ └── main
│ │ ├── res
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ ├── drawable-xxhdpi
│ │ │ ├── badge.png
│ │ │ ├── hearts.png
│ │ │ ├── landscape.jpg
│ │ │ ├── letter_t.png
│ │ │ └── microphone.png
│ │ ├── drawable
│ │ │ ├── baseline_line_style_24.xml
│ │ │ └── ic_launcher_background.xml
│ │ ├── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ └── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── dev
│ │ └── romainguy
│ │ └── text
│ │ └── combobreaker
│ │ └── demo
│ │ ├── ui
│ │ └── theme
│ │ │ ├── Shape.kt
│ │ │ ├── Type.kt
│ │ │ ├── Color.kt
│ │ │ └── Theme.kt
│ │ └── ComboBreakerActivity.kt
├── proguard-rules.pro
└── build.gradle
├── combo-breaker-material3
├── .gitignore
├── src
│ └── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── dev
│ │ └── romainguy
│ │ └── text
│ │ └── combobreaker
│ │ └── material3
│ │ └── TextFlow.kt
├── gradle.properties
└── build.gradle
├── art
├── screenshot_shapes.png
├── screenshot_default_shapes.png
├── screenshot_arbitrary_shapes.png
└── screenshot_styles_and_justification.png
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── settings.gradle
├── .github
└── workflows
│ └── continuous-build.yml
├── gradle.properties
├── .gitignore
├── gradlew.bat
├── gradlew
├── README.md
└── LICENSE
/.idea/.name:
--------------------------------------------------------------------------------
1 | Combo Breaker
--------------------------------------------------------------------------------
/combo-breaker/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/combo-breaker-demo/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/combo-breaker-material3/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/combo-breaker/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/combo-breaker-material3/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/art/screenshot_shapes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/art/screenshot_shapes.png
--------------------------------------------------------------------------------
/combo-breaker/gradle.properties:
--------------------------------------------------------------------------------
1 | POM_NAME=Combo Breaker
2 | POM_ARTIFACT_ID=combo-breaker
3 | POM_PACKAGING=aar
4 |
--------------------------------------------------------------------------------
/art/screenshot_default_shapes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/art/screenshot_default_shapes.png
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/art/screenshot_arbitrary_shapes.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/art/screenshot_arbitrary_shapes.png
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Combo Breaker Demo
3 |
--------------------------------------------------------------------------------
/art/screenshot_styles_and_justification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/art/screenshot_styles_and_justification.png
--------------------------------------------------------------------------------
/combo-breaker-material3/gradle.properties:
--------------------------------------------------------------------------------
1 | POM_NAME=Combo Breaker for Material3
2 | POM_ARTIFACT_ID=combo-breaker-material3
3 | POM_PACKAGING=aar
4 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable-xxhdpi/badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/combo-breaker-demo/src/main/res/drawable-xxhdpi/badge.png
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable-xxhdpi/hearts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/combo-breaker-demo/src/main/res/drawable-xxhdpi/hearts.png
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable-xxhdpi/landscape.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/combo-breaker-demo/src/main/res/drawable-xxhdpi/landscape.jpg
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable-xxhdpi/letter_t.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/combo-breaker-demo/src/main/res/drawable-xxhdpi/letter_t.png
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable-xxhdpi/microphone.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/romainguy/combo-breaker/HEAD/combo-breaker-demo/src/main/res/drawable-xxhdpi/microphone.png
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/AndroidProjectSystem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu May 08 15:47:01 PDT 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/.idea/deploymentTargetSelector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 |
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | }
15 | }
16 |
17 | rootProject.name = "Combo Breaker"
18 | include ':combo-breaker'
19 | include ':combo-breaker-material3'
20 | include ':combo-breaker-demo'
21 |
--------------------------------------------------------------------------------
/.github/workflows/continuous-build.yml:
--------------------------------------------------------------------------------
1 | name: Android
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v3
17 | - uses: actions/setup-java@v3
18 | with:
19 | distribution: 'temurin'
20 | java-version: '17'
21 | - name: Build library
22 | run: ./gradlew assembleRelease
23 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable/baseline_line_style_24.xml:
--------------------------------------------------------------------------------
1 |
4 |
6 |
7 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/combo-breaker-demo/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | GROUP=dev.romainguy
2 | VERSION_NAME=0.9.0
3 |
4 | POM_DESCRIPTION=Text layout for Compose to flow text around arbitrary shapes.
5 |
6 | POM_URL=https://github.com/romainguy/combo-breaker
7 | POM_SCM_URL=https://github.com/romainguy/combo-breaker
8 | POM_SCM_CONNECTION=scm:git:git://github.com/romainguy/combo-breaker.git
9 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/romainguy/combo-breaker.git
10 |
11 | POM_LICENCE_NAME=The Apache Software License, Version 2.0
12 | POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt
13 | POM_LICENCE_DIST=repo
14 |
15 | POM_DEVELOPER_ID=romainguy
16 | POM_DEVELOPER_NAME=Romain Guy
17 | POM_DEVELOPER_URL=https://github.com/romainguy
18 |
19 | android.disableResourceValidation=true
20 | android.nonTransitiveRClass=true
21 | android.nonFinalResIds=true
22 | android.useAndroidX=true
23 |
24 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled
25 |
26 | org.gradle.jvmargs=-Xmx512M
27 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
16 |
17 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
16 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
16 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/combo-breaker-material3/build.gradle:
--------------------------------------------------------------------------------
1 | import com.vanniktech.maven.publish.SonatypeHost
2 |
3 | plugins {
4 | id 'com.android.library'
5 | id 'kotlin-android'
6 | id 'org.jetbrains.dokka'
7 | id 'org.jetbrains.kotlin.plugin.compose'
8 | }
9 |
10 | group = GROUP
11 | version = VERSION_NAME
12 |
13 | android {
14 | namespace 'dev.romainguy.text.combobreaker.material3'
15 |
16 | defaultConfig {
17 | minSdk 29
18 | targetSdk 36
19 | compileSdk 36
20 |
21 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
22 | }
23 |
24 | compileOptions {
25 | sourceCompatibility JavaVersion.VERSION_21
26 | targetCompatibility JavaVersion.VERSION_21
27 | }
28 |
29 | kotlinOptions {
30 | jvmTarget = '21'
31 | }
32 |
33 | buildFeatures {
34 | compose true
35 | }
36 | }
37 |
38 | dependencies {
39 | implementation 'androidx.compose.material3:material3'
40 |
41 | api project(path: ':combo-breaker')
42 | }
43 |
44 | apply plugin: 'com.vanniktech.maven.publish'
45 |
46 | mavenPublishing {
47 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
48 | signAllPublications()
49 | }
50 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/MeasuredText.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.text.MeasuredText
20 | import androidx.annotation.RequiresApi
21 |
22 | internal object MeasuredTextHelper {
23 | @RequiresApi(33)
24 | fun hyphenation(builder: MeasuredText.Builder, hyphenation: TextFlowHyphenation) {
25 | if (hyphenation != TextFlowHyphenation.None) {
26 | builder.setComputeHyphenation(MeasuredText.Builder.HYPHENATION_MODE_NORMAL)
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ui/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker.demo.ui.theme
18 |
19 | import androidx.compose.foundation.shape.RoundedCornerShape
20 | import androidx.compose.material3.Shapes
21 | import androidx.compose.ui.unit.dp
22 |
23 | val comboBreakerShapes = Shapes(
24 | extraSmall = RoundedCornerShape(4.dp),
25 | small = RoundedCornerShape(8.dp),
26 | medium = RoundedCornerShape(16.dp),
27 | large = RoundedCornerShape(24.dp),
28 | extraLarge = RoundedCornerShape(32.dp)
29 | )
30 |
--------------------------------------------------------------------------------
/combo-breaker/build.gradle:
--------------------------------------------------------------------------------
1 | import com.vanniktech.maven.publish.SonatypeHost
2 |
3 | plugins {
4 | id 'com.android.library'
5 | id 'kotlin-android'
6 | id 'org.jetbrains.dokka'
7 | id 'org.jetbrains.kotlin.plugin.compose'
8 | }
9 |
10 | group = GROUP
11 | version = VERSION_NAME
12 |
13 | android {
14 | namespace 'dev.romainguy.text.combobreaker'
15 |
16 | defaultConfig {
17 | minSdk 29
18 | targetSdk 36
19 | compileSdk 36
20 |
21 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
22 | }
23 |
24 | compileOptions {
25 | sourceCompatibility JavaVersion.VERSION_21
26 | targetCompatibility JavaVersion.VERSION_21
27 | }
28 |
29 | kotlinOptions {
30 | jvmTarget = '21'
31 | }
32 |
33 | buildFeatures {
34 | compose true
35 | }
36 | }
37 |
38 | dependencies {
39 | implementation platform("androidx.compose:compose-bom:$compose_bom")
40 | implementation 'androidx.compose.ui:ui'
41 | implementation 'androidx.compose.material3:material3'
42 | }
43 |
44 | apply plugin: 'com.vanniktech.maven.publish'
45 |
46 | mavenPublishing {
47 | publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL)
48 | signAllPublications()
49 | }
50 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Locale.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2020 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.text.TextPaint
20 | import androidx.compose.ui.text.intl.Locale
21 | import androidx.compose.ui.text.intl.LocaleList
22 |
23 | internal fun Locale.toJavaLocale(): java.util.Locale = java.util.Locale(language, region)
24 |
25 | internal object LocaleListHelper {
26 | fun setTextLocales(textPaint: TextPaint, localeList: LocaleList) {
27 | textPaint.textLocales = android.os.LocaleList(
28 | *localeList.map { it.toJavaLocale() }.toTypedArray()
29 | )
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Utilities.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | * Copyright 2020 The Android Open Source Project
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | package dev.romainguy.text.combobreaker
19 |
20 | import androidx.compose.ui.Modifier
21 | import kotlin.contracts.ExperimentalContracts
22 | import kotlin.contracts.contract
23 |
24 | /**
25 | * Adds a [Modifier] to the modifiers chain if [condition] is true.
26 | */
27 | internal inline fun Modifier.thenIf(
28 | condition: Boolean,
29 | crossinline other: Modifier.() -> Modifier,
30 | ) = if (condition) other() else this
31 |
32 | @OptIn(ExperimentalContracts::class)
33 | internal inline fun List.fastForEachIndexed(action: (Int, T) -> Unit) {
34 | contract { callsInPlace(action) }
35 | for (index in indices) {
36 | val item = get(index)
37 | action(index, item)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/FlowShape.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import androidx.compose.ui.graphics.Path
20 |
21 | /**
22 | * Internal representation of a "flow shape". A flow shape is a [Path] contour from which
23 | * an interval tree is extracted. The intervals inside that tree are extracted by first flattening
24 | * the path as a series of segments. By taking the vertical interval of each segment, we can
25 | * build an interval tree that allows for fast queries when trying to layout a line of text.
26 | */
27 | internal class FlowShape(
28 | val path: Path,
29 | val flowType: FlowType,
30 | ) {
31 | internal val intervals = path.toIntervals()
32 | internal val bounds = path.getBounds()
33 |
34 | // These fields are read-write and used during layout to hold temporary information
35 | internal var min = Float.POSITIVE_INFINITY
36 | internal var max = Float.NEGATIVE_INFINITY
37 | }
38 |
--------------------------------------------------------------------------------
/combo-breaker-demo/build.gradle:
--------------------------------------------------------------------------------
1 | import com.vanniktech.maven.publish.SonatypeHost
2 |
3 | plugins {
4 | id 'com.android.application'
5 | id 'org.jetbrains.kotlin.android'
6 | id 'org.jetbrains.kotlin.plugin.compose'
7 | }
8 |
9 | android {
10 | namespace 'dev.romainguy.text.combobreaker.demo'
11 |
12 | defaultConfig {
13 | applicationId 'dev.romainguy.text.combobreaker.demo'
14 | minSdk 29
15 | targetSdk 36
16 | compileSdk 36
17 |
18 | versionCode 1
19 | versionName '1.0'
20 |
21 | vectorDrawables {
22 | useSupportLibrary true
23 | }
24 | }
25 |
26 | buildTypes {
27 | release {
28 | minifyEnabled false
29 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
30 | }
31 | }
32 |
33 | compileOptions {
34 | sourceCompatibility JavaVersion.VERSION_21
35 | targetCompatibility JavaVersion.VERSION_21
36 | }
37 |
38 | kotlinOptions {
39 | jvmTarget = '21'
40 | }
41 |
42 | buildFeatures {
43 | compose true
44 | }
45 |
46 | packagingOptions {
47 | resources {
48 | excludes += '/META-INF/{AL2.0,LGPL2.1}'
49 | }
50 | }
51 | }
52 |
53 | dependencies {
54 | implementation "androidx.core:core-ktx:$core_ktx_version"
55 |
56 | implementation platform("androidx.compose:compose-bom:$compose_bom")
57 | implementation 'androidx.compose.ui:ui'
58 | implementation 'androidx.compose.material3:material3'
59 |
60 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.0'
61 | implementation 'androidx.activity:activity-compose:1.10.1'
62 |
63 | implementation 'dev.romainguy:pathway:0.18.0'
64 |
65 | implementation project(path: ':combo-breaker-material3')
66 | }
67 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 |
3 | # Built application files
4 | *.apk
5 | *.aar
6 | *.ap_
7 | *.aab
8 |
9 | # Files for the ART/Dalvik VM
10 | *.dex
11 |
12 | # Java class files
13 | *.class
14 |
15 | # Generated files
16 | bin/
17 | gen/
18 | out/
19 | # Uncomment the following line in case you need and you don't have the release build type files in your app
20 | # release/
21 |
22 | # Gradle files
23 | .gradle/
24 | build/
25 |
26 | # Local configuration file (sdk path, etc)
27 | local.properties
28 |
29 | # Proguard folder generated by Eclipse
30 | proguard/
31 |
32 | # Log Files
33 | *.log
34 |
35 | # Android Studio Navigation editor temp files
36 | .navigation/
37 |
38 | # Android Studio captures folder
39 | captures/
40 |
41 | # IntelliJ
42 | *.iml
43 | .idea/workspace.xml
44 | .idea/tasks.xml
45 | .idea/gradle.xml
46 | .idea/assetWizardSettings.xml
47 | .idea/deploymentTargetDropDown.xml
48 | .idea/dictionaries
49 | .idea/libraries
50 | # Android Studio 3 in .gitignore file.
51 | .idea/caches
52 | .idea/modules.xml
53 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
54 | .idea/navEditor.xml
55 |
56 | # Keystore files
57 | # Uncomment the following lines if you do not want to check your keystore files in.
58 | #*.jks
59 | #*.keystore
60 |
61 | # External native build folder generated in Android Studio 2.2 and later
62 | .externalNativeBuild
63 | .cxx/
64 |
65 | # Google Services (e.g. APIs or Firebase)
66 | # google-services.json
67 |
68 | # Freeline
69 | freeline.py
70 | freeline/
71 | freeline_project_description.json
72 |
73 | # fastlane
74 | fastlane/report.xml
75 | fastlane/Preview.html
76 | fastlane/screenshots
77 | fastlane/test_output
78 | fastlane/readme.md
79 |
80 | # Version control
81 | vcs.xml
82 |
83 | # lint
84 | lint/intermediates/
85 | lint/generated/
86 | lint/outputs/
87 | lint/tmp/
88 | # lint/reports/
89 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Geometry.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.RectF
20 | import androidx.compose.ui.geometry.Offset
21 | import androidx.compose.ui.geometry.Size
22 | import androidx.compose.ui.graphics.Path
23 | import androidx.compose.ui.graphics.asAndroidPath
24 | import kotlin.math.max
25 | import kotlin.math.min
26 |
27 | internal class PathSegment(val x0: Float, val y0: Float, val x1: Float, val y1: Float)
28 |
29 | /**
30 | * Returns an [IntervalTree] for this path. Each [Interval] in that tree wraps a path segment
31 | * generated from flattening the path. The interval itself is the vertical interval of a given
32 | * segment. The resulting interval tree can be used to quickly query which segments intersect
33 | * a given interval such as a line of a text.
34 | */
35 | internal fun Path.toIntervals(
36 | intervals: IntervalTree = IntervalTree()
37 | ): IntervalTree {
38 | intervals.clear()
39 |
40 | // An error of 1 px is enough for our purpose as we don't need to AA the path
41 | val pathData = asAndroidPath().approximate(1.0f)
42 | val pointCount = pathData.size / 3
43 |
44 | if (pointCount > 1) {
45 | for (i in 1 until pointCount) {
46 | val index = i * 3
47 | val prevIndex = (i - 1) * 3
48 |
49 | val d = pathData[index]
50 | val x = pathData[index + 1]
51 | val y = pathData[index + 2]
52 |
53 | val pd = pathData[prevIndex]
54 | val px = pathData[prevIndex + 1]
55 | val py = pathData[prevIndex + 2]
56 |
57 | if (d != pd && (x != px || y != py)) {
58 | val segment = PathSegment(px, py, x, y)
59 | val intervalStart = min(segment.y0, segment.y1)
60 | val intervalEnd = max(segment.y0, segment.y1)
61 |
62 | intervals += Interval(intervalStart, intervalEnd, segment)
63 | }
64 | }
65 | }
66 |
67 | return intervals
68 | }
69 |
70 | @Suppress("NOTHING_TO_INLINE")
71 | internal inline fun RectF.toOffset() = Offset(left, top)
72 |
73 | @Suppress("NOTHING_TO_INLINE")
74 | internal inline fun RectF.toSize() = Size(width(), height())
75 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker.demo.ui.theme
18 |
19 | import androidx.compose.material3.Typography
20 | import androidx.compose.ui.text.TextStyle
21 | import androidx.compose.ui.text.font.FontWeight
22 | import androidx.compose.ui.unit.sp
23 |
24 | // Material 3 typography
25 | val comboBreakerTypography = Typography(
26 | headlineLarge = TextStyle(
27 | fontWeight = FontWeight.SemiBold,
28 | fontSize = 32.sp,
29 | lineHeight = 40.sp,
30 | letterSpacing = 0.sp
31 | ),
32 | headlineMedium = TextStyle(
33 | fontWeight = FontWeight.SemiBold,
34 | fontSize = 28.sp,
35 | lineHeight = 36.sp,
36 | letterSpacing = 0.sp
37 | ),
38 | headlineSmall = TextStyle(
39 | fontWeight = FontWeight.SemiBold,
40 | fontSize = 24.sp,
41 | lineHeight = 32.sp,
42 | letterSpacing = 0.sp
43 | ),
44 | titleLarge = TextStyle(
45 | fontWeight = FontWeight.SemiBold,
46 | fontSize = 22.sp,
47 | lineHeight = 28.sp,
48 | letterSpacing = 0.sp
49 | ),
50 | titleMedium = TextStyle(
51 | fontWeight = FontWeight.SemiBold,
52 | fontSize = 16.sp,
53 | lineHeight = 24.sp,
54 | letterSpacing = 0.15.sp
55 | ),
56 | titleSmall = TextStyle(
57 | fontWeight = FontWeight.Bold,
58 | fontSize = 14.sp,
59 | lineHeight = 20.sp,
60 | letterSpacing = 0.1.sp
61 | ),
62 | bodyLarge = TextStyle(
63 | fontWeight = FontWeight.Normal,
64 | fontSize = 16.sp,
65 | lineHeight = 24.sp,
66 | letterSpacing = 0.15.sp
67 | ),
68 | bodyMedium = TextStyle(
69 | fontWeight = FontWeight.Medium,
70 | fontSize = 14.sp,
71 | lineHeight = 20.sp,
72 | letterSpacing = 0.25.sp
73 | ),
74 | bodySmall = TextStyle(
75 | fontWeight = FontWeight.Bold,
76 | fontSize = 12.sp,
77 | lineHeight = 16.sp,
78 | letterSpacing = 0.4.sp
79 | ),
80 | labelLarge = TextStyle(
81 | fontWeight = FontWeight.SemiBold,
82 | fontSize = 14.sp,
83 | lineHeight = 20.sp,
84 | letterSpacing = 0.1.sp
85 | ),
86 | labelMedium = TextStyle(
87 | fontWeight = FontWeight.SemiBold,
88 | fontSize = 12.sp,
89 | lineHeight = 16.sp,
90 | letterSpacing = 0.5.sp
91 | ),
92 | labelSmall = TextStyle(
93 | fontWeight = FontWeight.SemiBold,
94 | fontSize = 11.sp,
95 | lineHeight = 16.sp,
96 | letterSpacing = 0.5.sp
97 | )
98 | )
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker.demo.ui.theme
18 |
19 | import androidx.compose.ui.graphics.Color
20 |
21 | // Generate them via theme builder
22 | // https://material-foundation.github.io/material-theme-builder/#/custom
23 |
24 | val md_theme_light_primary = Color(0xFF006492)
25 | val md_theme_light_onPrimary = Color(0xFFFFFFFF)
26 | val md_theme_light_primaryContainer = Color(0xFFCAE6FF)
27 | val md_theme_light_onPrimaryContainer = Color(0xFF001E2F)
28 | val md_theme_light_secondary = Color(0xFF50606E)
29 | val md_theme_light_onSecondary = Color(0xFFFFFFFF)
30 | val md_theme_light_secondaryContainer = Color(0xFFD3E5F5)
31 | val md_theme_light_onSecondaryContainer = Color(0xFF0C1D29)
32 | val md_theme_light_tertiary = Color(0xFF65597B)
33 | val md_theme_light_onTertiary = Color(0xFFFFFFFF)
34 | val md_theme_light_tertiaryContainer = Color(0xFFEBDDFF)
35 | val md_theme_light_onTertiaryContainer = Color(0xFF201634)
36 | val md_theme_light_error = Color(0xFFBA1A1A)
37 | val md_theme_light_errorContainer = Color(0xFFFFDAD6)
38 | val md_theme_light_onError = Color(0xFFFFFFFF)
39 | val md_theme_light_onErrorContainer = Color(0xFF410002)
40 | val md_theme_light_background = Color(0xFFFCFCFF)
41 | val md_theme_light_onBackground = Color(0xFF1A1C1E)
42 | val md_theme_light_surface = Color(0xFFFCFCFF)
43 | val md_theme_light_onSurface = Color(0xFF1A1C1E)
44 | val md_theme_light_surfaceVariant = Color(0xFFDDE3EA)
45 | val md_theme_light_onSurfaceVariant = Color(0xFF41474D)
46 | val md_theme_light_outline = Color(0xFF72787E)
47 | val md_theme_light_inverseOnSurface = Color(0xFFF0F0F3)
48 | val md_theme_light_inverseSurface = Color(0xFF2E3133)
49 | val md_theme_light_inversePrimary = Color(0xFF8CCDFF)
50 | val md_theme_light_surfaceTint = Color(0xFF006492)
51 | val md_theme_light_outlineVariant = Color(0xFFC1C7CE)
52 | val md_theme_light_scrim = Color(0xFF000000)
53 |
54 | val md_theme_dark_primary = Color(0xFF8CCDFF)
55 | val md_theme_dark_onPrimary = Color(0xFF00344E)
56 | val md_theme_dark_primaryContainer = Color(0xFF004B6F)
57 | val md_theme_dark_onPrimaryContainer = Color(0xFFCAE6FF)
58 | val md_theme_dark_secondary = Color(0xFFB7C9D9)
59 | val md_theme_dark_onSecondary = Color(0xFF22323F)
60 | val md_theme_dark_secondaryContainer = Color(0xFF384956)
61 | val md_theme_dark_onSecondaryContainer = Color(0xFFD3E5F5)
62 | val md_theme_dark_tertiary = Color(0xFFCFC0E8)
63 | val md_theme_dark_onTertiary = Color(0xFF362B4B)
64 | val md_theme_dark_tertiaryContainer = Color(0xFF4C4162)
65 | val md_theme_dark_onTertiaryContainer = Color(0xFFEBDDFF)
66 | val md_theme_dark_error = Color(0xFFFFB4AB)
67 | val md_theme_dark_errorContainer = Color(0xFF93000A)
68 | val md_theme_dark_onError = Color(0xFF690005)
69 | val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
70 | val md_theme_dark_background = Color(0xFF1A1C1E)
71 | val md_theme_dark_onBackground = Color(0xFFE2E2E5)
72 | val md_theme_dark_surface = Color(0xFF1A1C1E)
73 | val md_theme_dark_onSurface = Color(0xFFE2E2E5)
74 | val md_theme_dark_surfaceVariant = Color(0xFF41474D)
75 | val md_theme_dark_onSurfaceVariant = Color(0xFFC1C7CE)
76 | val md_theme_dark_outline = Color(0xFF8B9198)
77 | val md_theme_dark_inverseOnSurface = Color(0xFF1A1C1E)
78 | val md_theme_dark_inverseSurface = Color(0xFFE2E2E5)
79 | val md_theme_dark_inversePrimary = Color(0xFF006492)
80 | val md_theme_dark_surfaceTint = Color(0xFF8CCDFF)
81 | val md_theme_dark_outlineVariant = Color(0xFF41474D)
82 | val md_theme_dark_scrim = Color(0xFF000000)
83 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/Clipping.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.PointF
20 | import android.graphics.RectF
21 | import kotlin.math.abs
22 |
23 | // Clipping flags indicating where clipping must occur
24 | private const val ClipNone = 0
25 | private const val ClipTop = 1
26 | private const val ClipBottom = 2
27 | private const val ClipLeft = 4
28 | private const val ClipRight = 8
29 |
30 | /**
31 | * Clips the segment described by [p1] and [p2] against the rectangle [r]. This function
32 | * modifies the two points [p1] and [p2] directly. The [scratch] parameter is used as a temporary
33 | * allocation.
34 | *
35 | * @return True if the segment intersects with or is inside [r], false otherwise.
36 | */
37 | internal fun clipSegment(p1: PointF, p2: PointF, r: RectF, scratch: PointF): Boolean {
38 | // Find the types of clipping required
39 | var clip1 = clipType(p1, r)
40 | var clip2 = clipType(p2, r)
41 |
42 | while (true) {
43 | // Return when we are fully inside or fully outside the rectangle
44 | if ((clip1 or clip2) == ClipNone) return true
45 | if ((clip1 and clip2) != ClipNone) return false
46 |
47 | // No need to test for the case where both end points are outside of the rectangle
48 | // since we only test against parts of the path that overlap the text interval
49 |
50 | // Choose one of the two end points to treat first
51 | val clipType = if (clip1 != ClipNone) clip1 else clip2
52 | intersection(p1, p2, r, clipType, scratch)
53 |
54 | // Depending on which end point we clipped, update the segment and recompute the
55 | // clip flags for the modified end point
56 | if (clipType == clip1) {
57 | p1.set(scratch)
58 | clip1 = clipType(p1, r)
59 | } else {
60 | p2.set(scratch)
61 | clip2 = clipType(p2, r)
62 | }
63 | }
64 | }
65 |
66 | /**
67 | * Computes the clip flags required for [p] to lie on or inside the rectangle [r]
68 | */
69 | private fun clipType(p: PointF, r: RectF): Int {
70 | var clip = ClipNone
71 | if (p.y < r.top) clip = clip or ClipTop
72 | if (p.y > r.bottom) clip = clip or ClipBottom
73 | if (p.x < r.left) clip = clip or ClipLeft
74 | if (p.x > r.right) clip = clip or ClipRight
75 | return clip
76 | }
77 |
78 | /**
79 | * Computes and return the intersection of a segment defined by [p1] and [p2] with the
80 | * rectangle [r]. The intersection point is written to [out]. The [clip] parameter
81 | * indicates which clipping operations to perform on the segment to find the intersection.
82 | */
83 | private fun intersection(p1: PointF, p2: PointF, r: RectF, clip: Int, out: PointF) {
84 | val dx = p1.x - p2.x
85 | val dy = p1.y - p2.y
86 |
87 | // TODO: The threshold used here should be good enough to deal with colinear segments
88 | // since they are expressed in pixels, but we should probably find a better way
89 | val sx = if (abs(dx) < 1e-3f) 0.0f else dy / dx
90 | val sy = if (abs(dy) < 1e-3f) 0.0f else dx / dy
91 |
92 | if ((clip and ClipTop) != 0) {
93 | out.set(p1.x + sy * (r.top - p1.y), r.top)
94 | return
95 | }
96 |
97 | if ((clip and ClipBottom) != 0) {
98 | out.set(p1.x + sy * (r.bottom - p1.y), r.bottom)
99 | return
100 | }
101 |
102 | if ((clip and ClipLeft) != 0) {
103 | out.set(r.left, p1.y + sx * (r.left - p1.x))
104 | return
105 | }
106 |
107 | if ((clip and ClipRight) != 0) {
108 | out.set(r.right, p1.y + sx * (r.right - p1.x))
109 | return
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker.demo.ui.theme
18 |
19 | import android.app.Activity
20 | import android.os.Build
21 | import androidx.compose.foundation.isSystemInDarkTheme
22 | import androidx.compose.material3.MaterialTheme
23 | import androidx.compose.material3.darkColorScheme
24 | import androidx.compose.material3.dynamicDarkColorScheme
25 | import androidx.compose.material3.dynamicLightColorScheme
26 | import androidx.compose.material3.lightColorScheme
27 | import androidx.compose.runtime.Composable
28 | import androidx.compose.runtime.SideEffect
29 | import androidx.compose.ui.graphics.toArgb
30 | import androidx.compose.ui.platform.LocalContext
31 | import androidx.compose.ui.platform.LocalView
32 | import androidx.core.view.WindowCompat
33 |
34 | // Material 3 color schemes
35 | private val comboBreakerLightColorScheme = lightColorScheme(
36 | primary = md_theme_light_primary,
37 | onPrimary = md_theme_light_onPrimary,
38 | primaryContainer = md_theme_light_primaryContainer,
39 | onPrimaryContainer = md_theme_light_onPrimaryContainer,
40 | secondary = md_theme_light_secondary,
41 | onSecondary = md_theme_light_onSecondary,
42 | secondaryContainer = md_theme_light_secondaryContainer,
43 | onSecondaryContainer = md_theme_light_onSecondaryContainer,
44 | tertiary = md_theme_light_tertiary,
45 | onTertiary = md_theme_light_onTertiary,
46 | tertiaryContainer = md_theme_light_tertiaryContainer,
47 | onTertiaryContainer = md_theme_light_onTertiaryContainer,
48 | error = md_theme_light_error,
49 | errorContainer = md_theme_light_errorContainer,
50 | onError = md_theme_light_onError,
51 | onErrorContainer = md_theme_light_onErrorContainer,
52 | background = md_theme_light_background,
53 | onBackground = md_theme_light_onBackground,
54 | surface = md_theme_light_surface,
55 | onSurface = md_theme_light_onSurface,
56 | surfaceVariant = md_theme_light_surfaceVariant,
57 | onSurfaceVariant = md_theme_light_onSurfaceVariant,
58 | outline = md_theme_light_outline,
59 | inverseOnSurface = md_theme_light_inverseOnSurface,
60 | inverseSurface = md_theme_light_inverseSurface,
61 | inversePrimary = md_theme_light_inversePrimary,
62 | surfaceTint = md_theme_light_surfaceTint,
63 | outlineVariant = md_theme_light_outlineVariant,
64 | scrim = md_theme_light_scrim,
65 | )
66 |
67 | private val comboBreakerDarkColorScheme = darkColorScheme(
68 | primary = md_theme_dark_primary,
69 | onPrimary = md_theme_dark_onPrimary,
70 | primaryContainer = md_theme_dark_primaryContainer,
71 | onPrimaryContainer = md_theme_dark_onPrimaryContainer,
72 | secondary = md_theme_dark_secondary,
73 | onSecondary = md_theme_dark_onSecondary,
74 | secondaryContainer = md_theme_dark_secondaryContainer,
75 | onSecondaryContainer = md_theme_dark_onSecondaryContainer,
76 | tertiary = md_theme_dark_tertiary,
77 | onTertiary = md_theme_dark_onTertiary,
78 | tertiaryContainer = md_theme_dark_tertiaryContainer,
79 | onTertiaryContainer = md_theme_dark_onTertiaryContainer,
80 | error = md_theme_dark_error,
81 | errorContainer = md_theme_dark_errorContainer,
82 | onError = md_theme_dark_onError,
83 | onErrorContainer = md_theme_dark_onErrorContainer,
84 | background = md_theme_dark_background,
85 | onBackground = md_theme_dark_onBackground,
86 | surface = md_theme_dark_surface,
87 | onSurface = md_theme_dark_onSurface,
88 | surfaceVariant = md_theme_dark_surfaceVariant,
89 | onSurfaceVariant = md_theme_dark_onSurfaceVariant,
90 | outline = md_theme_dark_outline,
91 | inverseOnSurface = md_theme_dark_inverseOnSurface,
92 | inverseSurface = md_theme_dark_inverseSurface,
93 | inversePrimary = md_theme_dark_inversePrimary,
94 | surfaceTint = md_theme_dark_surfaceTint,
95 | outlineVariant = md_theme_dark_outlineVariant,
96 | scrim = md_theme_dark_scrim,
97 | )
98 |
99 | @Composable
100 | fun ComboBreakerTheme(
101 | darkTheme: Boolean = isSystemInDarkTheme(),
102 | dynamicColor: Boolean = true,
103 | content: @Composable () -> Unit
104 | ) {
105 | val comboBreakerColorScheme = when {
106 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
107 | val context = LocalContext.current
108 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
109 | }
110 | darkTheme -> comboBreakerDarkColorScheme
111 | else -> comboBreakerLightColorScheme
112 | }
113 | val view = LocalView.current
114 | if (!view.isInEditMode) {
115 | SideEffect {
116 | val window = (view.context as Activity).window
117 | window.statusBarColor = comboBreakerColorScheme.primary.toArgb()
118 | WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme
119 | }
120 | }
121 |
122 | MaterialTheme(
123 | colorScheme = comboBreakerColorScheme,
124 | typography = comboBreakerTypography,
125 | shapes = comboBreakerShapes,
126 | content = content
127 | )
128 | }
129 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Combo Breaker
2 |
3 | [](https://maven-badges.herokuapp.com/maven-central/dev.romainguy/combo-breaker)
4 | [](https://maven-badges.herokuapp.com/maven-central/dev.romainguy/combo-breaker-material3)
5 | [](https://github.com/romainguy/combo-breaker/actions?query=workflow%3AAndroid)
6 |
7 | Composable widget for Jetpack Compose that allows to flow text around arbitrary shapes over
8 | multiple columns. The `TextFlow` composable behaves as a `Box` layout and will automatically
9 | flow the text content around its children.
10 |
11 | * [Features](#features)
12 | * [Design Systems](#design-systems)
13 | * [Examples](#examples)
14 | * [Maven](#maven)
15 | * [Roadmap](#roadmap)
16 | * [License](#license)
17 | * [Attribution](#attribution)
18 |
19 | ## Features
20 |
21 | - Multi-column layout
22 | - Styled strings (`AnnotatedString`)
23 | - Default rectangular shapes
24 | - Arbitrary shapes (any `Path`)
25 | - Justification
26 | - Hyphenation
27 | - Compatible with API 29+
28 |
29 | ## Design Systems
30 |
31 | Combo Breaker provides two levels of APIs depending on what design system you use:
32 |
33 | - `BasicTextFlow` from the `dev.romainguy:combo-breaker` artifact, which works with any design system
34 | - `TextFlow` from the `dev.romainguy:combo-breaker-material3` artifact, which works with Material3
35 |
36 | Choose `BasicTextFlow` if you do not have or do not want a dependency on
37 | `androidx.compose.material3:material3`.
38 |
39 | ## Examples
40 |
41 | The following code defines two images to flow text around:
42 |
43 | ```kotlin
44 | TextFlow(
45 | SampleText,
46 | style = TextStyle(fontSize = 14.sp),
47 | columns = 2
48 | ) {
49 | Image(
50 | bitmap = letterT.asImageBitmap(),
51 | contentDescription = "",
52 | modifier = Modifier
53 | .flowShape(FlowType.OutsideEnd)
54 | )
55 |
56 | Image(
57 | bitmap = badgeBitmap.asImageBitmap(),
58 | contentDescription = "",
59 | modifier = Modifier
60 | .align(Alignment.Center)
61 | .flowShape(margin = 6.dp)
62 | )
63 | }
64 | ```
65 |
66 | 
67 |
68 | Any child of `TextFlow` allows text to flow around a rectangular shape of the same dimensions of the
69 | child. The `flowShape` modifier is used to control where text flows around the shape (to the
70 | right/end of the T) and around both the left and right sides of the landscape photo (default
71 | behavior). In addition, you can define a margin around the shape.
72 |
73 | The `flowShape` modifier also lets you specify a specific shape instead of a default rectangle.
74 | This can be done by passing a `Path` or a lambda that returns a `Path`. The lambda alternative
75 | is useful when you need to create a `Path` based on the dimensions of the `TextFlow` or the
76 | dimensions of its child.
77 |
78 | Here is an example of a `TextFlow` using non-rectangular shapes:
79 |
80 | ```kotlin
81 | val microphoneShape = microphoneBitmap.toPath(alphaThreshold = 0.5f).asComposePath()
82 | val badgeShape = badgeShape.toPath(alphaThreshold = 0.5f).asComposePath()
83 |
84 | TextFlow(
85 | SampleText,
86 | style = TextStyle(fontSize = 14.sp),
87 | columns = 2
88 | ) {
89 | Image(
90 | bitmap = microphoneBitmap.asImageBitmap(),
91 | contentDescription = "",
92 | modifier = Modifier
93 | .offset { Offset(-microphoneBitmap.width / 4.5f, 0.0f).round() }
94 | .flowShape(FlowType.OutsideEnd, 6.dp, microphoneShape)
95 | )
96 |
97 | Image(
98 | bitmap = badgeBitmap.asImageBitmap(),
99 | contentDescription = "",
100 | modifier = Modifier
101 | .align(Alignment.Center)
102 | .flowShape(FlowType.Outside, 6.dp, badgeShape)
103 | )
104 | }
105 | ```
106 |
107 | The non-rectangular `Path` shape is created using the extension `Bitmap.toPath` from the
108 | [pathway](https://github.com/romainguy/pathway) library. Using that API, a shape can be extracted
109 | from a bitmap and used as the flow shape for the desired child:
110 |
111 | 
112 |
113 | `TextFlow` supports multiple text styles and lets you control justification and hyphenation. In
114 | the example below, both justification and hyphenation are enabled:
115 |
116 | 
117 |
118 | You can also specify multiple shapes for any given element by using the `flowShapes` modifiers
119 | instead of `flowShape`. `flowShapes` accepts/returns list of paths instead of a single path.
120 | For instance, with [pathway](https://github.com/romainguy/pathway) you can easily extract a list of
121 | paths from a `Bitmap` by using `Bitmap.toPaths()` instead of `Bitmap.toPath()`.
122 |
123 | ```kotlin
124 | val heartsShapes = heartsBitmap.toPaths().map { it.asComposePath() }
125 |
126 | TextFlow(
127 | SampleText,
128 | style = TextStyle(fontSize = 12.sp),
129 | columns = 2
130 | ) {
131 | Image(
132 | bitmap = heartsBitmap.asImageBitmap(),
133 | contentDescription = "",
134 | modifier = Modifier
135 | .align(Alignment.Center)
136 | .flowShapes(FlowType.Outside, 4.dp, heartsShapes)
137 | )
138 | }
139 | ```
140 |
141 | This creates many shapes around which the text can flow:
142 |
143 | 
144 |
145 | ## Maven
146 |
147 | ```gradle
148 | repositories {
149 | // ...
150 | mavenCentral()
151 | }
152 |
153 | dependencies {
154 | // Use this library and BasicTextFlow() if you don't want a dependency on material3
155 | implementation 'dev.romainguy:combo-breaker:0.9.0'
156 |
157 | // Use this library and TextFlow() if you use material3
158 | implementation 'dev.romainguy:combo-breaker-material3:0.9.0'
159 | }
160 | ```
161 |
162 | ## Roadmap
163 |
164 | - Backport to earlier API levels.
165 | - Lines containing styles of different line heights can lead to improper flow around certain shapes.
166 | - More comprehensive `TextFlowLayoutResult`.
167 | - Add support to ellipsize the last line when the entire text cannot fit in the layout area.
168 | - Add support for text-relative placement of flow shapes.
169 | - Implement margins support without relying on `Path.op` which can be excessively expensive with
170 | complex paths.
171 | - BiDi text hasn't been tested yet, and probably doesn't work properly (RTL layouts are however
172 | supported for the placement of flow shapes and the handling of columns).
173 | - Improve performance of contours extraction from an image (could be multi-threaded for instance).
174 | - Investigate an alternative and simpler way to handle placement around shapes (beam cast instead
175 | of the purely geometric approach that currently requires a lot of intersection work).
176 | - Support flowing text inside shapes.
177 |
178 | ## License
179 |
180 | Please see [LICENSE](./LICENSE).
181 |
182 | ## Attribution
183 |
184 | The render of the microphone was made possible thanks to
185 | [RCA 44-BX Microphone](https://skfb.ly/6AKHx) by Tom Seddon, licensed under
186 | [Creative Commons Attribution](http://creativecommons.org/licenses/by/4.0/).
187 |
188 | Sample text taken from the [Wikipedia Hyphen article](https://en.wikipedia.org/wiki/Hyphen).
189 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/IntervalTree.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import kotlin.math.max
20 | import kotlin.math.min
21 |
22 | /**
23 | * Interval in an [IntervalTree]. The interval is defined between a [start] and an [end]
24 | * coordinates, whose meanings are defined by the caller. An interval can also hold
25 | * arbitrary [data] to be used to looking at the result of queries with
26 | * [IntervalTree.findOverlaps].
27 | */
28 | internal class Interval(val start: Float, val end: Float, val data: T? = null) {
29 | /**
30 | * Returns trues if this interval overlaps with another interval.
31 | */
32 | fun overlaps(other: Interval) = start <= other.end && end >= other.start
33 | }
34 |
35 | /**
36 | * An interval tree holds a list of intervals and allows for fast queries of intervals
37 | * that overlap any given interval. This can be used for instance to perform fast spatial
38 | * queries like finding all the segments in a path that overlap with a given vertical
39 | * interval.
40 | */
41 | internal class IntervalTree {
42 | // Note: this interval tree is implemented as a binary red/black tree that gets
43 | // re-balanced on updates. There's nothing notable about this particular data
44 | // structure beyond what can be found in various descriptions of binary search
45 | // trees and red/black trees
46 |
47 | private val terminator = Node(
48 | Interval(Float.MAX_VALUE, Float.MIN_VALUE, null),
49 | TreeColor.Black
50 | )
51 | private var root = terminator
52 |
53 | /**
54 | * Clears this tree and prepares it for reuse. After calling [clear], any call to
55 | * [findOverlaps] returns false.
56 | */
57 | fun clear() {
58 | root = terminator
59 | }
60 |
61 | /**
62 | * Finds all the intervals that overlap with the specified [interval]. If [results]
63 | * is specified, [results] is returned, otherwise a new [MutableList] is returned.
64 | */
65 | fun findOverlaps(
66 | interval: Interval,
67 | results: MutableList> = mutableListOf()
68 | ): MutableList> {
69 | if (root !== terminator) {
70 | findOverlaps(root, interval, results)
71 | }
72 | return results
73 | }
74 |
75 | private fun findOverlaps(
76 | node: Node,
77 | interval: Interval,
78 | results: MutableList>
79 | ) {
80 | if (node.interval.overlaps(interval)) results.add(node.interval)
81 | if (node.left !== terminator && node.left.max >= interval.start) {
82 | findOverlaps(node.left, interval, results)
83 | }
84 | if (node.right !== terminator && node.right.min <= interval.end) {
85 | findOverlaps(node.right, interval, results)
86 | }
87 | }
88 |
89 | /**
90 | * Adds the specified [Interval] to the interval tree.
91 | */
92 | operator fun plusAssign(interval: Interval) {
93 | val node = Node(interval)
94 |
95 | // Update the tree without doing any balancing
96 | var current = root
97 | var parent = terminator
98 |
99 | while (current !== terminator) {
100 | parent = current
101 | current = if (node.interval.start <= current.interval.start) {
102 | current.left
103 | } else {
104 | current.right
105 | }
106 | }
107 |
108 | node.parent = parent
109 |
110 | if (parent === terminator) {
111 | root = node
112 | } else {
113 | if (node.interval.start <= parent.interval.start) {
114 | parent.left = node
115 | } else {
116 | parent.right = node
117 | }
118 | }
119 |
120 | updateNodeData(node)
121 |
122 | rebalance(node)
123 | }
124 |
125 | private fun rebalance(target: Node) {
126 | var node = target
127 |
128 | while (node !== root && node.parent.color == TreeColor.Red) {
129 | val ancestor = node.parent.parent
130 | if (node.parent === ancestor.left) {
131 | val right = ancestor.right
132 | if (right.color == TreeColor.Red) {
133 | right.color = TreeColor.Black
134 | node.parent.color = TreeColor.Black
135 | ancestor.color = TreeColor.Red
136 | node = ancestor
137 | } else {
138 | if (node === node.parent.right) {
139 | node = node.parent
140 | rotateLeft(node)
141 | }
142 | node.parent.color = TreeColor.Black
143 | ancestor.color = TreeColor.Red
144 | rotateRight(ancestor)
145 | }
146 | } else {
147 | val left = ancestor.left
148 | if (left.color == TreeColor.Red) {
149 | left.color = TreeColor.Black
150 | node.parent.color = TreeColor.Black
151 | ancestor.color = TreeColor.Red
152 | node = ancestor
153 | } else {
154 | if (node === node.parent.left) {
155 | node = node.parent
156 | rotateRight(node)
157 | }
158 | node.parent.color = TreeColor.Black
159 | ancestor.color = TreeColor.Red
160 | rotateLeft(ancestor)
161 | }
162 | }
163 | }
164 |
165 | root.color = TreeColor.Black
166 | }
167 |
168 | private fun rotateLeft(node: Node) {
169 | val right = node.right
170 | node.right = right.left
171 |
172 | if (right.left !== terminator) {
173 | right.left.parent = node
174 | }
175 |
176 | right.parent = node.parent
177 |
178 | if (node.parent === terminator) {
179 | root = right
180 | } else {
181 | if (node.parent.left === node) {
182 | node.parent.left = right
183 | } else {
184 | node.parent.right = right
185 | }
186 | }
187 |
188 | right.left = node
189 | node.parent = right
190 |
191 | updateNodeData(node)
192 | }
193 |
194 | private fun rotateRight(node: Node) {
195 | val left = node.left
196 | node.left = left.right
197 |
198 | if (left.right !== terminator) {
199 | left.right.parent = node
200 | }
201 |
202 | left.parent = node.parent
203 |
204 | if (node.parent === terminator) {
205 | root = left
206 | } else {
207 | if (node.parent.right === node) {
208 | node.parent.right = left
209 | } else {
210 | node.parent.left = left
211 | }
212 | }
213 |
214 | left.right = node
215 | node.parent = left
216 |
217 | updateNodeData(node)
218 | }
219 |
220 | private fun updateNodeData(node: Node) {
221 | var current = node
222 | while (current !== terminator) {
223 | current.min = min(current.interval.start, min(current.left.min, current.right.min))
224 | current.max = max(current.interval.end, max(current.left.max, current.right.max))
225 | current = current.parent
226 | }
227 | }
228 |
229 | private enum class TreeColor {
230 | Red, Black
231 | }
232 |
233 | private inner class Node(val interval: Interval, var color: TreeColor = TreeColor.Red) {
234 | var min: Float = interval.start
235 | var max: Float = interval.end
236 |
237 | var left: Node = terminator
238 | var right: Node = terminator
239 | var parent: Node = terminator
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/TextPaint.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2020 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.Paint
20 | import android.graphics.Typeface
21 | import android.os.Build
22 | import android.text.TextPaint
23 | import androidx.compose.runtime.State
24 | import androidx.compose.ui.geometry.Size
25 | import androidx.compose.ui.geometry.isSpecified
26 | import androidx.compose.ui.graphics.Brush
27 | import androidx.compose.ui.graphics.Color
28 | import androidx.compose.ui.graphics.ShaderBrush
29 | import androidx.compose.ui.graphics.Shadow
30 | import androidx.compose.ui.graphics.SolidColor
31 | import androidx.compose.ui.graphics.isSpecified
32 | import androidx.compose.ui.graphics.toArgb
33 | import androidx.compose.ui.text.ExperimentalTextApi
34 | import androidx.compose.ui.text.SpanStyle
35 | import androidx.compose.ui.text.TextStyle
36 | import androidx.compose.ui.text.font.FontFamily
37 | import androidx.compose.ui.text.font.FontStyle
38 | import androidx.compose.ui.text.font.FontSynthesis
39 | import androidx.compose.ui.text.font.FontWeight
40 | import androidx.compose.ui.text.intl.Locale
41 | import androidx.compose.ui.text.intl.LocaleList
42 | import androidx.compose.ui.text.style.BaselineShift
43 | import androidx.compose.ui.text.style.TextDecoration
44 | import androidx.compose.ui.text.style.TextGeometricTransform
45 | import androidx.compose.ui.unit.Density
46 | import androidx.compose.ui.unit.TextUnit
47 | import androidx.compose.ui.unit.TextUnitType
48 |
49 | private fun TextPaint.setTextDecoration(textDecoration: TextDecoration?) {
50 | if (textDecoration == null) return
51 | isUnderlineText = TextDecoration.Underline in textDecoration
52 | isStrikeThruText = TextDecoration.LineThrough in textDecoration
53 | }
54 |
55 | private fun TextPaint.setShadow(shadow: Shadow?) {
56 | if (shadow == null) return
57 | if (shadow == Shadow.None) {
58 | clearShadowLayer()
59 | } else {
60 | setShadowLayer(
61 | correctBlurRadius(shadow.blurRadius),
62 | shadow.offset.x,
63 | shadow.offset.y,
64 | shadow.color.toArgb()
65 | )
66 | }
67 | }
68 |
69 | private fun TextPaint.setColor(color: Color) {
70 | if (color.isSpecified) {
71 | this.color = color.toArgb()
72 | this.shader = null
73 | }
74 | }
75 |
76 | private fun TextPaint.setBrush(brush: Brush?, size: Size, alpha: Float = Float.NaN) {
77 | // if size is unspecified and brush is not null, nothing should be done.
78 | // it basically means brush is given but size is not yet calculated at this time.
79 | if ((brush is SolidColor && brush.value.isSpecified) ||
80 | (brush is ShaderBrush && size.isSpecified)) {
81 | // alpha is always applied even if Float.NaN is passed to applyTo function.
82 | // if it's actually Float.NaN, we simply send the current value
83 | val p = androidx.compose.ui.graphics.Paint()
84 | brush.applyTo(
85 | size,
86 | p,
87 | if (alpha.isNaN()) this.alpha / 255.0f else alpha.coerceIn(0f, 1f)
88 | )
89 | val fwkPaint = p.asFrameworkPaint()
90 | this.alpha = fwkPaint.alpha
91 | this.shader = fwkPaint.shader
92 | this.color = fwkPaint.color
93 | } else if (brush == null) {
94 | shader = null
95 | }
96 | }
97 |
98 | /**
99 | * Applies given [SpanStyle] to this [TextPaint].
100 | *
101 | * Although most attributes in [SpanStyle] can be applied to [TextPaint], some are only applicable
102 | * as regular platform spans such as background, baselineShift. This function also returns a new
103 | * [SpanStyle] that consists of attributes that were not applied to the [TextPaint].
104 | */
105 | @OptIn(ExperimentalTextApi::class)
106 | fun TextPaint.applySpanStyle(
107 | style: SpanStyle,
108 | resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface,
109 | density: Density,
110 | ): SpanStyle {
111 | when (style.fontSize.type) {
112 | TextUnitType.Sp -> with(density) {
113 | textSize = style.fontSize.toPx()
114 | }
115 | TextUnitType.Em -> {
116 | textSize *= style.fontSize.value
117 | }
118 | else -> {} // Do nothing
119 | }
120 |
121 | if (style.hasFontAttributes()) {
122 | typeface = resolveTypeface(
123 | style.fontFamily,
124 | style.fontWeight ?: FontWeight.Normal,
125 | style.fontStyle ?: FontStyle.Normal,
126 | style.fontSynthesis ?: FontSynthesis.All
127 | )
128 | }
129 |
130 | if (style.localeList != null && style.localeList != LocaleList.current) {
131 | if (Build.VERSION.SDK_INT >= 24) {
132 | LocaleListHelper.setTextLocales(this, style.localeList!!)
133 | } else {
134 | val locale = if (style.localeList!!.isEmpty()) {
135 | Locale.current
136 | } else {
137 | style.localeList!![0]
138 | }
139 | textLocale = locale.toJavaLocale()
140 | }
141 | }
142 |
143 | when (style.letterSpacing.type) {
144 | TextUnitType.Em -> { letterSpacing = style.letterSpacing.value }
145 | TextUnitType.Sp -> {} // Sp will be handled by applying a span
146 | else -> {} // Do nothing
147 | }
148 |
149 | if (style.fontFeatureSettings != null && style.fontFeatureSettings != "") {
150 | fontFeatureSettings = style.fontFeatureSettings
151 | }
152 |
153 | if (style.textGeometricTransform != null &&
154 | style.textGeometricTransform != TextGeometricTransform(1.0f, 0.0f)
155 | ) {
156 | textScaleX *= style.textGeometricTransform!!.scaleX
157 | textSkewX += style.textGeometricTransform!!.skewX
158 | }
159 |
160 | // these parameters are also updated by the Paragraph.paint
161 |
162 | setColor(style.color)
163 | // setBrush draws the text with given Brush. ShaderBrush requires Size to
164 | // create a Shader. However, Size is unavailable at this stage of the layout.
165 | // Paragraph.paint will receive a proper Size after layout is completed.
166 | setBrush(style.brush, Size.Unspecified, style.alpha)
167 | setShadow(style.shadow)
168 | setTextDecoration(style.textDecoration)
169 |
170 | // letterSpacing with unit Sp needs to be handled by span.
171 | // baselineShift and bgColor is reset in the Android Layout constructor,
172 | // therefore we cannot apply them on paint, have to use spans.
173 | return SpanStyle(
174 | letterSpacing = if (style.letterSpacing.type == TextUnitType.Sp &&
175 | style.letterSpacing.value != 0f
176 | ) {
177 | style.letterSpacing
178 | } else {
179 | TextUnit.Unspecified
180 | },
181 | background = if (style.background == Color.Transparent) {
182 | Color.Unspecified // No need to add transparent background for default text style.
183 | } else {
184 | style.background
185 | },
186 | baselineShift = if (style.baselineShift == BaselineShift.None) {
187 | null
188 | } else {
189 | style.baselineShift
190 | }
191 | )
192 | }
193 |
194 | /**
195 | * Returns true if this [SpanStyle] contains any font style attributes set.
196 | */
197 | private fun SpanStyle.hasFontAttributes(): Boolean {
198 | return fontFamily != null || fontStyle != null || fontWeight != null
199 | }
200 |
201 | /**
202 | * Platform shadow layer turns off shadow when blur is zero. Where as developers expect when blur
203 | * is zero, the shadow is still visible but without any blur. This utility function is used
204 | * while setting shadow on spans or paint in order to replace 0 with Float.MIN_VALUE so that the
205 | * shadow will still be visible and the blur is practically 0.
206 | */
207 | private fun correctBlurRadius(blurRadius: Float) = if (blurRadius == 0f) {
208 | Float.MIN_VALUE
209 | } else {
210 | blurRadius
211 | }
212 |
213 | private class TypefaceDirtyTracker(resolveResult: State) {
214 | val initial = resolveResult.value
215 | val typeface: Typeface
216 | get() = initial as Typeface
217 | }
218 |
219 | internal fun createTextPaint(
220 | fontFamilyResolver: FontFamily.Resolver,
221 | style: TextStyle,
222 | density: Density
223 | ) = TextPaint().apply {
224 | val resolvedTypefaces: MutableList = mutableListOf()
225 | val resolveTypeface: (FontFamily?, FontWeight, FontStyle, FontSynthesis) -> Typeface =
226 | { fontFamily, fontWeight, fontStyle, fontSynthesis ->
227 | val result = fontFamilyResolver.resolve(
228 | fontFamily,
229 | fontWeight,
230 | fontStyle,
231 | fontSynthesis
232 | )
233 | val holder = TypefaceDirtyTracker(result)
234 | resolvedTypefaces.add(holder)
235 | holder.typeface
236 | }
237 | applySpanStyle(style.toSpanStyle(), resolveTypeface, density)
238 | isAntiAlias = true
239 | }
240 |
241 | internal inline val Paint.lineHeight get() = fontMetrics.descent - fontMetrics.ascent
242 |
--------------------------------------------------------------------------------
/combo-breaker-material3/src/main/java/dev/romainguy/text/combobreaker/material3/TextFlow.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker.material3
18 |
19 | import androidx.compose.material3.LocalTextStyle
20 | import androidx.compose.runtime.Composable
21 | import androidx.compose.runtime.derivedStateOf
22 | import androidx.compose.runtime.getValue
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.ui.Alignment
25 | import androidx.compose.ui.Modifier
26 | import androidx.compose.ui.text.AnnotatedString
27 | import androidx.compose.ui.text.TextStyle
28 | import androidx.compose.ui.unit.Dp
29 | import androidx.compose.ui.unit.dp
30 | import dev.romainguy.text.combobreaker.*
31 |
32 | /**
33 | * A layout composable with [content] that can flow [text] around the shapes defined by the
34 | * elements of [content].
35 | *
36 | * The specified text will be laid out inside a number of columns defined by the [columns]
37 | * parameter, each separated by white space defined by [columnSpacing]. All the children
38 | * from [content] define *flow shapes* that text will flow around. How text flows around any
39 | * given shape is defined by the [FlowType] of that element/shape. The flow shape and its
40 | * flow type can be defined for a given element by using the [TextFlowScope.flowShape] modifier.
41 | *
42 | * The default flow shape of each element is a rectangle of the same dimensions as the element
43 | * itself, with a flow type set to [FlowType.Outside].
44 | *
45 | * The [TextFlow] will size itself to fit the [content], subject to the incoming constraints.
46 | * When children are smaller than the parent, by default they will be positioned inside
47 | * the [TextFlow] according to the [contentAlignment]. For individually specifying the alignments
48 | * of the children layouts, use the [TextFlowScope.align] modifier.
49 | *
50 | * Text justification can be controlled with the [justification] parameter, but it is strongly
51 | * recommended to leave it on to provide balanced flow around non-rectangular shapes with a flow
52 | * type set to [FlowType.Outside].
53 | *
54 | * Text hyphenation can be controlled with the [hyphenation] parameter, but will only work on
55 | * API level that support hyphenation control (API 33+). It is also recommended to keep hyphenation
56 | * turned on to provide more balanced results.
57 | *
58 | * By default, the content will be measured without the [TextFlow]'s incoming min constraints,
59 | * unless [propagateMinConstraints] is `true`. As an example, setting [propagateMinConstraints] to
60 | * `true` can be useful when the [TextFlow] has content on which modifiers cannot be specified
61 | * directly and setting a min size on the content of the [TextFlow] is needed. If
62 | * [propagateMinConstraints] is set to `true`, the min size set on the [TextFlow] will also be
63 | * applied to the content, whereas otherwise the min size will only apply to the [TextFlow].
64 | *
65 | * When the content has more than one layout child the layout children will be stacked one
66 | * on top of the other (positioned as explained above) in the composition order.
67 | *
68 | * @param text The text to layout around the shapes defined by the content's elements.
69 | * @param modifier The modifier to be applied to the layout.
70 | * @param style The default text style to apply to [text].
71 | * @param justification Sets the type of text justification.
72 | * @param hyphenation Sets the type of text hyphenation (only on supported API levels).
73 | * @param columns The desired number of columns to layout [text] with.
74 | * @param columnSpacing The amount of space between two adjacent columns.
75 | * @param onTextFlowLayoutResult Will be invoked with information about the text layout.
76 | * @param contentAlignment The default alignment inside the layout.
77 | * @param propagateMinConstraints Whether the incoming min constraints should be passed to content.
78 | * @param debugOverlay Used for debugging only.
79 | * @param content The content of the [TextFlow]. Each element in the content defines a flow shape
80 | * that is taken into account to layout [text].
81 | */
82 | @Composable
83 | fun TextFlow(
84 | text: String,
85 | modifier: Modifier = Modifier,
86 | style: TextStyle = LocalTextStyle.current,
87 | justification: TextFlowJustification = TextFlowJustification.None,
88 | hyphenation: TextFlowHyphenation = TextFlowHyphenation.Auto,
89 | columns: Int = 1,
90 | columnSpacing: Dp = 16.dp,
91 | onTextFlowLayoutResult: (result: TextFlowLayoutResult) -> Unit = { },
92 | contentAlignment: Alignment = Alignment.TopStart,
93 | propagateMinConstraints: Boolean = false,
94 | debugOverlay: Boolean = false,
95 | content: @Composable TextFlowScope.() -> Unit
96 | ) {
97 | val annotatedText by remember(text, style) {
98 | derivedStateOf { AnnotatedString(text, style.toSpanStyle()) }
99 | }
100 |
101 | BasicTextFlow(
102 | annotatedText,
103 | style,
104 | modifier,
105 | justification,
106 | hyphenation,
107 | columns,
108 | columnSpacing,
109 | onTextFlowLayoutResult,
110 | contentAlignment,
111 | propagateMinConstraints,
112 | debugOverlay,
113 | content
114 | )
115 | }
116 |
117 | /**
118 | * A layout composable with [content] that can flow [text] around the shapes defined by the
119 | * elements of [content].
120 | *
121 | * The specified text will be laid out inside a number of columns defined by the [columns]
122 | * parameter, each separated by white space defined by [columnSpacing]. All the children
123 | * from [content] define *flow shapes* that text will flow around. How text flows around any
124 | * given shape is defined by the [FlowType] of that element/shape. The flow shape and its
125 | * flow type can be defined for a given element by using the [TextFlowScope.flowShape] modifier.
126 | *
127 | * The default flow shape of each element is a rectangle of the same dimensions as the element
128 | * itself, with a flow type set to [FlowType.Outside].
129 | *
130 | * The [TextFlow] will size itself to fit the [content], subject to the incoming constraints.
131 | * When children are smaller than the parent, by default they will be positioned inside
132 | * the [TextFlow] according to the [contentAlignment]. For individually specifying the alignments
133 | * of the children layouts, use the [TextFlowScope.align] modifier.
134 | *
135 | * Text justification can be controlled with the [justification] parameter, but it is strongly
136 | * recommended to leave it on to provide balanced flow around non-rectangular shapes with a flow
137 | * type set to [FlowType.Outside].
138 | *
139 | * Text hyphenation can be controlled with the [hyphenation] parameter, but will only work on
140 | * API level that support hyphenation control (API 33+). It is also recommended to keep hyphenation
141 | * turned on to provide more balanced results.
142 | *
143 | * By default, the content will be measured without the [TextFlow]'s incoming min constraints,
144 | * unless [propagateMinConstraints] is `true`. As an example, setting [propagateMinConstraints] to
145 | * `true` can be useful when the [TextFlow] has content on which modifiers cannot be specified
146 | * directly and setting a min size on the content of the [TextFlow] is needed. If
147 | * [propagateMinConstraints] is set to `true`, the min size set on the [TextFlow] will also be
148 | * applied to the content, whereas otherwise the min size will only apply to the [TextFlow].
149 | *
150 | * When the content has more than one layout child the layout children will be stacked one
151 | * on top of the other (positioned as explained above) in the composition order.
152 | *
153 | * @param text The text to layout around the shapes defined by the content's elements.
154 | * @param modifier The modifier to be applied to the layout.
155 | * @param style The default text style to apply to [text].
156 | * @param justification Sets the type of text justification.
157 | * @param hyphenation Sets the type of text hyphenation (only on supported API levels).
158 | * @param columns The desired number of columns to layout [text] with.
159 | * @param columnSpacing The amount of space between two adjacent columns.
160 | * @param onTextFlowLayoutResult Will be invoked with information about the text layout.
161 | * @param contentAlignment The default alignment inside the layout.
162 | * @param propagateMinConstraints Whether the incoming min constraints should be passed to content.
163 | * @param debugOverlay Used for debugging only.
164 | * @param content The content of the [TextFlow]. Each element in the content defines a flow shape
165 | * that is taken into account to layout [text].
166 | */
167 | @Composable
168 | fun TextFlow(
169 | text: AnnotatedString,
170 | modifier: Modifier = Modifier,
171 | style: TextStyle = LocalTextStyle.current,
172 | justification: TextFlowJustification = TextFlowJustification.None,
173 | hyphenation: TextFlowHyphenation = TextFlowHyphenation.Auto,
174 | columns: Int = 1,
175 | columnSpacing: Dp = 16.dp,
176 | onTextFlowLayoutResult: (result: TextFlowLayoutResult) -> Unit = { },
177 | contentAlignment: Alignment = Alignment.TopStart,
178 | propagateMinConstraints: Boolean = false,
179 | debugOverlay: Boolean = false,
180 | content: @Composable TextFlowScope.() -> Unit
181 | ) {
182 | BasicTextFlow(
183 | text,
184 | style,
185 | modifier,
186 | justification,
187 | hyphenation,
188 | columns,
189 | columnSpacing,
190 | onTextFlowLayoutResult,
191 | contentAlignment,
192 | propagateMinConstraints,
193 | debugOverlay,
194 | content
195 | )
196 | }
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/FlowSlots.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.PointF
20 | import android.graphics.RectF
21 | import kotlin.math.max
22 | import kotlin.math.min
23 |
24 | private val RectComparator = Comparator { r1: RectF, r2: RectF -> (r1.left - r2.left).toInt() }
25 |
26 | /**
27 | * Holder for pre-allocated structures that will be used when findFlowSlots() is called
28 | * repeatedly.
29 | */
30 | internal class FlowSlotFinderState {
31 | val slots: ArrayList = ArrayList()
32 |
33 | val intervals: ArrayList> = ArrayList()
34 | val flowShapeHits: ArrayList = ArrayList()
35 |
36 | val p1: PointF = PointF()
37 | val p2: PointF = PointF()
38 | val scratch: PointF = PointF()
39 | }
40 |
41 | /**
42 | * Given a layout area [box], finds all the slots (rectangles) that can be used to layout
43 | * content around a series of given flow shapes. In our case [box] will be a line of text
44 | * but it could be any other area.
45 | *
46 | * The resulting list will honor the [FlowType] of each [FlowShape], allowing content to lay
47 | * only on one side, both sides, or no side of the shape.
48 | *
49 | * @param box Rectangle representing the area in which we want to layout content.
50 | * @param container Bounds of the [box] container, which will typically match [box] exactly.
51 | * unless text is laid out over multiple columns/shapes.
52 | * @param flowShapes List of shapes that content must flow around.
53 | * @param state Optional [FlowSlotFinderState] structure to avoid allocations across invocations.
54 | * @param results Optional for debug only: holds the list of [Interval] used to find slots.
55 | *
56 | * @return A list of rectangles indicating where content can be laid out.
57 | */
58 | internal fun findFlowSlots(
59 | box: RectF,
60 | container: RectF,
61 | flowShapes: ArrayList,
62 | state: FlowSlotFinderState = FlowSlotFinderState(),
63 | results: MutableList>? = null
64 | ): List {
65 | var foundIntervals = false
66 | val searchInterval = Interval(box.top, box.bottom)
67 |
68 | val slots = state.slots
69 | val intervals = state.intervals
70 | val flowShapeHits = state.flowShapeHits
71 | val p1 = state.p1
72 | val p2 = state.p2
73 |
74 | slots.clear()
75 | flowShapeHits.clear()
76 |
77 | val flowShapeCount = flowShapes.size
78 | for (i in 0 until flowShapeCount) {
79 | val flowShape = flowShapes[i]
80 |
81 | // Ignore shapes outside of the box or with a flow type of "none"
82 | if (quickReject(flowShape, box.top, box.bottom)) continue
83 |
84 | flowShapeHits.add(flowShape)
85 |
86 | // We first find all the intervals for the current shape that intersect
87 | // with the box (layout area/line of text). We'll then go through that
88 | // list to find what part of the box lies outside the shape
89 | intervals.clear()
90 | flowShape.intervals.findOverlaps(searchInterval, intervals)
91 | foundIntervals = foundIntervals || intervals.size != 0
92 |
93 | // Find the left-most (min) and right-most (max) x coordinates of all
94 | // the shape segments that intersect the box. This will tell us where
95 | // we can safely layout content to the left (min) and right (max) of
96 | // the shape
97 | var shapeMin = box.right
98 | var shapeMax = box.left
99 |
100 | val intervalCount = intervals.size
101 | for (j in 0 until intervalCount) {
102 | val interval = intervals[j]
103 |
104 | val segment = interval.data
105 | checkNotNull(segment)
106 |
107 | // p1 and p2 will be modified by the [clipSegment] function, which is why
108 | // we don't pass segment.start/end directly
109 | p1.set(segment.x0, segment.y0)
110 | p2.set(segment.x1, segment.y1)
111 |
112 | if (clipSegment(p1, p2, container, state.scratch)) {
113 | shapeMin = min(shapeMin, min(p1.x, p2.x))
114 | shapeMax = max(shapeMax, max(p1.x, p2.x))
115 | }
116 | }
117 |
118 | flowShape.min = shapeMin
119 | flowShape.max = shapeMax
120 |
121 | addReducedSlots(flowShape.flowType, box, shapeMin, shapeMax, slots)
122 |
123 | results?.addAll(intervals)
124 | }
125 |
126 | applyFlowShapeExclusions(flowShapeHits, slots)
127 |
128 | // If we haven't found any new slot because we never even found overlapping shapes,
129 | // consider the entire layout area as valid
130 | if (slots.size == 0 && !foundIntervals) {
131 | slots.add(RectF(box))
132 | }
133 |
134 | slots.sortWith(RectComparator)
135 |
136 | return slots
137 | }
138 |
139 | /**
140 | * Apply the exclusion zones of the selected flow shapes to the given list of slots.
141 | * For instance, a flow shape with a type set to [FlowType.OutsideRight] will prevent
142 | * any text to flow to its own left.
143 | *
144 | * @param flowShapes List of flow shapes that need to apply their exclusion zones.
145 | * @param slots List of slots to modify by intersecting them against flow shape exclusion zones.
146 | */
147 | private fun applyFlowShapeExclusions(
148 | flowShapes: MutableList,
149 | slots: MutableList
150 | ) {
151 | // Fix-up slots by applying exclusion zones from flow shapes
152 | val flowShapeHitCount = flowShapes.size
153 | for (i in 0 until flowShapeHitCount) {
154 | val hit = flowShapes[i]
155 | // We do NOT want to do what's below for shapes marked as Outside, so we
156 | // check for the actual types instead of looking at isLeft/RightFlow which
157 | // work for Outside as well
158 | val leftFlow = hit.flowType == FlowType.OutsideLeft
159 | val rightFlow = hit.flowType == FlowType.OutsideRight
160 |
161 | val slotCount = slots.size
162 | for (j in 0 until slotCount) {
163 | val slot = slots[j]
164 | if (leftFlow) {
165 | slot.right = min(slot.right, hit.min)
166 | } else if (rightFlow) {
167 | slot.left = max(slot.left, hit.max)
168 | } else if (slot.left >= hit.min && slot.right <= hit.max) {
169 | // The slot is entirely inside another flow shape, so we discard it
170 | slot.setEmpty()
171 | }
172 | }
173 | }
174 | }
175 |
176 | /**
177 | * Given a layout area defined by [box] and a flow shape with the specified [min] and [max]
178 | * extents, add 0, 1, or 2 new slots to the [slots] list. The number of slots added depends
179 | * on the flow [type] of the shape ([FlowType.Outside] for instance tries to add 2 slots,
180 | * while [FlowType.OutsideLeft] would try to add only 1). While adding new slots, this function
181 | * may also reduce the existing slots presents in the [slots] list by performing intersection
182 | * tests between the new slots and the existing ones.
183 | *
184 | * For instance if we start with a right flowing shape (marked by X's) and find its slot
185 | * (drawn as a rectangle), we obtain:
186 | *
187 | * XX __________________________________
188 | * XX | |
189 | * XX ----------------------------------
190 | *
191 | * Adding a left flowing slot:
192 | *
193 | * ________________________________ XXX
194 | * | | XXX
195 | * -------------------------------- XXX
196 | *
197 | * This function will intersect the left flowing slot with our first slot to produce:
198 | *
199 | * XX _____________________________ XXX
200 | * XX | | XXX
201 | * XX ----------------------------- XXX
202 | *
203 | * If we then add a left-and-right flowing shape defined as thus:
204 | *
205 | * ________________XXX_________________
206 | * | XXX |
207 | * ----------------XXX-----------------
208 | *
209 | * The function will intersect the left slots of this shape with the existing slot, then
210 | * add a new intersect slot to the right to produce:
211 | *
212 | * XX ____________ XXX ____________ XXX
213 | * XX | |XXX| | XXX
214 | * XX ------------ XXX ------------ XXX
215 | *
216 | * When the new slots to add don't overlap with any existing slots, they are directly added
217 | * to the list of slots.
218 | */
219 | private fun addReducedSlots(
220 | type: FlowType,
221 | box: RectF,
222 | min: Float,
223 | max: Float,
224 | slots: MutableList
225 | ) {
226 | val left = box.left
227 | val top = box.top
228 | val right = box.right
229 | val bottom = box.bottom
230 |
231 | val isLeftFlow = type.isLeftFlow
232 | val isRightFlow = type.isRightFlow
233 |
234 | var foundLeftOverlap = false
235 | var foundRightOverlap = false
236 |
237 | val slotCount = slots.size
238 | for (i in 0 until slotCount) {
239 | val ancestor = slots[i]
240 |
241 | val leftOverlap = isLeftFlow && ancestor.left < min && left < ancestor.right
242 | val rightOverlap = isRightFlow && ancestor.left < right && max < ancestor.right
243 |
244 | if (leftOverlap && rightOverlap) {
245 | // Intersect with left slot, add right slot, intersected also
246 | val rightSlot = RectF(ancestor)
247 | ancestor.horizontalIntersect(left, min)
248 | rightSlot.horizontalIntersect(max, right)
249 | slots.add(rightSlot)
250 | } else {
251 | if (leftOverlap) {
252 | ancestor.horizontalIntersect(left, min)
253 | } else if (rightOverlap) {
254 | ancestor.horizontalIntersect(max, right)
255 | }
256 | }
257 |
258 | foundLeftOverlap = foundLeftOverlap || leftOverlap
259 | foundRightOverlap = foundRightOverlap || rightOverlap
260 | }
261 |
262 | if (!foundLeftOverlap && isLeftFlow && left < min) {
263 | slots.add(RectF(left, top, min, bottom))
264 | }
265 |
266 | if (!foundRightOverlap && isRightFlow && max < right) {
267 | slots.add(RectF(max, top, right, bottom))
268 | }
269 | }
270 |
271 | /**
272 | * Quickly decide whether the specified flow shape can be rejected by checking whether
273 | * it lies outside of the vertical interval defined by [top] and [bottom]. A [FlowShape]
274 | * is also rejected with its [FlowShape.flowType] is [FlowType.None] since it won't
275 | * participate in layout.
276 | */
277 | @Suppress("NOTHING_TO_INLINE")
278 | private inline fun quickReject(flowShape: FlowShape, top: Float, bottom: Float) =
279 | flowShape.flowType == FlowType.None ||
280 | top > flowShape.bounds.bottom ||
281 | bottom < flowShape.bounds.top
282 |
283 | /**
284 | * Behaves like RectF.intersect(float, float, float, float) but only deals with the
285 | * left and right parameters.
286 | */
287 | private fun RectF.horizontalIntersect(left: Float, right: Float) {
288 | if (this.left < right && left < this.right) {
289 | if (this.left < left) this.left = left
290 | if (this.right > right) this.right = right
291 | }
292 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/combo-breaker-demo/src/main/java/dev/romainguy/text/combobreaker/demo/ComboBreakerActivity.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker.demo
18 |
19 | import android.graphics.Bitmap
20 | import android.graphics.BitmapFactory
21 | import android.os.Bundle
22 | import androidx.activity.ComponentActivity
23 | import androidx.activity.compose.setContent
24 | import androidx.compose.foundation.Image
25 | import androidx.compose.foundation.layout.Column
26 | import androidx.compose.foundation.layout.Row
27 | import androidx.compose.foundation.layout.Spacer
28 | import androidx.compose.foundation.layout.fillMaxSize
29 | import androidx.compose.foundation.layout.fillMaxWidth
30 | import androidx.compose.foundation.layout.offset
31 | import androidx.compose.foundation.layout.padding
32 | import androidx.compose.foundation.layout.width
33 | import androidx.compose.material3.Checkbox
34 | import androidx.compose.material3.ExperimentalMaterial3Api
35 | import androidx.compose.material3.LocalTextStyle
36 | import androidx.compose.material3.MaterialTheme
37 | import androidx.compose.material3.Scaffold
38 | import androidx.compose.material3.Slider
39 | import androidx.compose.material3.Surface
40 | import androidx.compose.material3.Text
41 | import androidx.compose.material3.TopAppBar
42 | import androidx.compose.material3.TopAppBarDefaults
43 | import androidx.compose.runtime.Composable
44 | import androidx.compose.runtime.derivedStateOf
45 | import androidx.compose.runtime.getValue
46 | import androidx.compose.runtime.mutableStateOf
47 | import androidx.compose.runtime.remember
48 | import androidx.compose.runtime.setValue
49 | import androidx.compose.ui.Alignment
50 | import androidx.compose.ui.Modifier
51 | import androidx.compose.ui.geometry.Offset
52 | import androidx.compose.ui.graphics.asComposePath
53 | import androidx.compose.ui.graphics.asImageBitmap
54 | import androidx.compose.ui.text.SpanStyle
55 | import androidx.compose.ui.text.TextStyle
56 | import androidx.compose.ui.text.buildAnnotatedString
57 | import androidx.compose.ui.text.font.FontStyle
58 | import androidx.compose.ui.text.font.FontWeight
59 | import androidx.compose.ui.text.withStyle
60 | import androidx.compose.ui.unit.IntOffset
61 | import androidx.compose.ui.unit.dp
62 | import androidx.compose.ui.unit.round
63 | import androidx.compose.ui.unit.sp
64 | import dev.romainguy.text.combobreaker.FlowType
65 | import dev.romainguy.text.combobreaker.TextFlowHyphenation
66 | import dev.romainguy.text.combobreaker.TextFlowJustification
67 | import dev.romainguy.text.combobreaker.demo.ui.theme.ComboBreakerTheme
68 | import dev.romainguy.text.combobreaker.material3.TextFlow
69 | import dev.romainguy.graphics.path.toPath
70 | import dev.romainguy.graphics.path.toPaths
71 |
72 | class ComboBreakerActivity : ComponentActivity() {
73 | override fun onCreate(savedInstanceState: Bundle?) {
74 | super.onCreate(savedInstanceState)
75 |
76 | setContent {
77 | MaterialDemoSetup {
78 | TextFlowDemo()
79 | }
80 | }
81 | }
82 |
83 | @Composable
84 | private fun TextFlowDemo() {
85 | val colorScheme = MaterialTheme.colorScheme
86 |
87 | var columns by remember { mutableStateOf(2) }
88 | var useMultipleShapes by remember { mutableStateOf(false) }
89 | var useRectangleShapes by remember { mutableStateOf(true) }
90 | var isJustified by remember { mutableStateOf(false) }
91 | var isHyphenated by remember { mutableStateOf(true) }
92 | var isDebugOverlayEnabled by remember { mutableStateOf(true) }
93 |
94 | //region Sample text
95 | val sampleText by remember { derivedStateOf {
96 | buildAnnotatedString {
97 | withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp)) {
98 | if (useMultipleShapes || !useRectangleShapes) {
99 | append("T")
100 | }
101 | append("he Hyphen")
102 | }
103 | append("\n\n")
104 | append("The ")
105 | withStyle(style = SpanStyle(color = colorScheme.primary)) {
106 | append("English language ")
107 | }
108 | append("does not have definitive hyphenation rules, though various ")
109 | withStyle(style = SpanStyle(color = colorScheme.primary)) {
110 | append("style guides ")
111 | }
112 | append("provide detailed usage recommendations and have a significant ")
113 | append("amount of overlap in what they advise. Hyphens are mostly used to break single ")
114 | append("words into parts or to join ordinarily separate words into single words. Spaces ")
115 | append("are not placed between a hyphen and either of the elements it connects except ")
116 | append("when using a suspended or ")
117 | withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
118 | append("\"hanging\" ")
119 | }
120 | append("hyphen that stands in for a repeated word (e.g., nineteenth- and ")
121 | append("twentieth-century writers). Style conventions that apply to hyphens (and ")
122 | append("dashes) have evolved to support ease of reading in complex constructions; ")
123 | append("editors often accept deviations if they aid rather than hinder easy ")
124 | append("comprehension.\n\n")
125 |
126 | append("The use of the hyphen in ")
127 | withStyle(style = SpanStyle(color = colorScheme.primary)) {
128 | append("English compound ")
129 | }
130 | append("nouns and verbs has, in general, ")
131 | append("been steadily declining. Compounds that might once have been hyphenated are ")
132 | append("increasingly left with spaces or are combined into one word. Reflecting this ")
133 | append("changing usage, in 2007, the sixth edition of the ")
134 | withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
135 | append("Shorter Oxford English ")
136 | }
137 | append("Dictionary removed the hyphens from 16,000 entries, such as fig-leaf (now fig ")
138 | append("leaf), pot-belly (now pot belly), and pigeon-hole (now pigeonhole). The ")
139 | append("increasing prevalence of computer technology and the advent of the Internet ")
140 | append("have given rise to a subset of common nouns that might have been hyphenated ")
141 | append("in the past (e.g., toolbar, hyperlink, and pastebin).\n\n")
142 |
143 | append("Despite decreased use, hyphenation remains the norm in certain ")
144 | append("compound-modifier constructions and, among some authors, with certain ")
145 | append("prefixes (see below). Hyphenation is also routinely used as part of ")
146 | append("syllabification in justified texts to avoid unsightly spacing (especially ")
147 | append("in columns with narrow line lengths, as when used with newspapers).")
148 | }
149 | } }
150 | //endregion
151 |
152 | val microphone = remember {
153 | BitmapFactory.decodeResource(resources, R.drawable.microphone).let {
154 | Bitmap.createScaledBitmap(it, it.width / 2, it.height / 2, true)
155 | }
156 | }
157 | val microphoneShape by remember {
158 | derivedStateOf { microphone.toPath(alphaThreshold = 0.5f).asComposePath() }
159 | }
160 |
161 | val badge = remember { BitmapFactory.decodeResource(resources, R.drawable.badge) }
162 | val badgeShape by remember {
163 | derivedStateOf { badge.toPath().asComposePath() }
164 | }
165 |
166 | val letterT = remember { BitmapFactory.decodeResource(resources, R.drawable.letter_t) }
167 | val landscape = remember { BitmapFactory.decodeResource(resources, R.drawable.landscape) }
168 |
169 | val hearts = remember { BitmapFactory.decodeResource(resources, R.drawable.hearts) }
170 | val heartsShape by remember {
171 | derivedStateOf {
172 | if (useMultipleShapes) {
173 | hearts.toPaths().map { it.asComposePath() }
174 | } else {
175 | emptyList()
176 | }
177 | }
178 | }
179 |
180 | val justification by remember {
181 | derivedStateOf {
182 | if (isJustified) {
183 | TextFlowJustification.Auto
184 | } else {
185 | TextFlowJustification.None
186 | }
187 | }
188 | }
189 | val hyphenation by remember {
190 | derivedStateOf {
191 | if (isHyphenated) {
192 | TextFlowHyphenation.Auto
193 | } else {
194 | TextFlowHyphenation.None
195 | }
196 | }
197 | }
198 |
199 | Column(modifier = Modifier.padding(16.dp)) {
200 | TextFlow(
201 | sampleText,
202 | modifier = Modifier.weight(1.0f).fillMaxWidth(),
203 | style = LocalTextStyle.current.merge(
204 | TextStyle(
205 | color = colorScheme.onSurface,
206 | fontSize = if (useMultipleShapes) 13.sp else LocalTextStyle.current.fontSize
207 | )
208 | ),
209 | justification = justification,
210 | hyphenation = hyphenation,
211 | columns = columns,
212 | debugOverlay = isDebugOverlayEnabled
213 | ) {
214 | if (!useMultipleShapes) {
215 | Image(
216 | bitmap = (if (useRectangleShapes) letterT else microphone).asImageBitmap(),
217 | contentDescription = "",
218 | modifier = Modifier
219 | .offset {
220 | if (useRectangleShapes)
221 | IntOffset(0, 0)
222 | else
223 | Offset(-microphone.width / 4.5f, 0.0f).round()
224 | }
225 | .flowShape(
226 | FlowType.OutsideEnd,
227 | if (useRectangleShapes) 0.dp else 8.dp,
228 | if (useRectangleShapes) null else microphoneShape
229 | )
230 | )
231 |
232 | Image(
233 | bitmap = (if (useRectangleShapes) landscape else badge).asImageBitmap(),
234 | contentDescription = "",
235 | modifier = Modifier
236 | .align(Alignment.Center)
237 | .flowShape(
238 | FlowType.Outside,
239 | if (useRectangleShapes) 8.dp else 10.dp,
240 | if (useRectangleShapes) null else badgeShape
241 | )
242 | )
243 | } else {
244 | Image(
245 | bitmap = hearts.asImageBitmap(),
246 | contentDescription = "",
247 | modifier = Modifier
248 | .align(Alignment.Center)
249 | .flowShapes(FlowType.Outside, 2.dp, heartsShape)
250 | )
251 | }
252 | }
253 |
254 | DemoControls(
255 | columns, { columns = it },
256 | useRectangleShapes, { useRectangleShapes = it },
257 | useMultipleShapes, { useMultipleShapes = it },
258 | isJustified, { isJustified = it},
259 | isHyphenated, { isHyphenated = it},
260 | isDebugOverlayEnabled, { isDebugOverlayEnabled = it},
261 | )
262 | }
263 | }
264 |
265 | @Composable
266 | private fun DemoControls(
267 | columns: Int,
268 | onColumnsChanged: (Int) -> Unit,
269 | useRectangleShapes: Boolean,
270 | onUseRectangleShapesChanged: (Boolean) -> Unit,
271 | useMultipleShapes: Boolean,
272 | onUseMultipleShapesChanged: (Boolean) -> Unit,
273 | justify: Boolean,
274 | onJustifyChanged: (Boolean) -> Unit,
275 | hyphenation: Boolean,
276 | onHyphenationChanged: (Boolean) -> Unit,
277 | debugOverlay: Boolean,
278 | onDebugOverlayChanged: (Boolean) -> Unit,
279 | ) {
280 | Row(verticalAlignment = Alignment.CenterVertically) {
281 | Text(text = "Columns")
282 | Spacer(modifier = Modifier.width(8.dp))
283 | Slider(
284 | modifier = Modifier.weight(0.1f),
285 | value = columns.toFloat(),
286 | onValueChange = { onColumnsChanged(it.toInt()) },
287 | valueRange = 1.0f..4.0f
288 | )
289 | Spacer(modifier = Modifier.width(8.dp))
290 |
291 | Checkbox(checked = useRectangleShapes, onCheckedChange = onUseRectangleShapesChanged)
292 | Text(text = "Rects")
293 |
294 | Checkbox(checked = useMultipleShapes, onCheckedChange = onUseMultipleShapesChanged)
295 | Text(text = "Multi")
296 | }
297 |
298 | Row(verticalAlignment = Alignment.CenterVertically) {
299 | Checkbox(checked = justify, onCheckedChange = onJustifyChanged)
300 | Text(text = "Justify")
301 |
302 | Checkbox(checked = hyphenation, onCheckedChange = onHyphenationChanged)
303 | Text(text = "Hyphenate")
304 |
305 | Checkbox(checked = debugOverlay, onCheckedChange = onDebugOverlayChanged)
306 | Text(text = "Debug")
307 | }
308 | }
309 |
310 | @Composable
311 | @OptIn(ExperimentalMaterial3Api::class)
312 | private fun MaterialDemoSetup(content: @Composable () -> Unit) {
313 | ComboBreakerTheme {
314 | Scaffold(
315 | topBar = {
316 | TopAppBar(
317 | title = { Text("Combo Breaker Demo") },
318 | colors = TopAppBarDefaults.mediumTopAppBarColors(
319 | containerColor = MaterialTheme.colorScheme.primary,
320 | titleContentColor = MaterialTheme.colorScheme.onPrimary
321 | )
322 | )
323 | },
324 | ) { padding ->
325 | Surface(
326 | modifier = Modifier
327 | .fillMaxSize()
328 | .padding(paddingValues = padding),
329 | color = MaterialTheme.colorScheme.surface,
330 | content = content
331 | )
332 | }
333 | }
334 | }
335 | }
336 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/TextLayout.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.Paint
20 | import android.graphics.RectF
21 | import android.graphics.text.LineBreaker
22 | import android.graphics.text.MeasuredText
23 | import android.os.Build
24 | import android.text.TextPaint
25 | import androidx.compose.ui.text.AnnotatedString
26 | import androidx.compose.ui.text.SpanStyle
27 | import androidx.compose.ui.text.TextStyle
28 | import androidx.compose.ui.text.font.FontFamily
29 | import androidx.compose.ui.unit.Density
30 | import androidx.compose.ui.unit.IntSize
31 | import androidx.compose.ui.unit.LayoutDirection
32 | import kotlin.math.max
33 | import kotlin.math.min
34 |
35 | /**
36 | * Internal representation of a line of text computed by the text flow layout algorithm
37 | * and used to render text.
38 | *
39 | * Note the term "line" is used loosely here, as it could be just a chunk of a visual
40 | * line. Most of the time this will be a full line though.
41 | *
42 | * @param buffer The text buffer to render text from (see [start] and [end].
43 | * @param start The start offset in the [buffer] buffer.
44 | * @param end The end offset in the [buffer] buffer.
45 | * @param startHyphen The start hyphen value for this class, as expected by
46 | * [TextPaint.setStartHyphenEdit].
47 | * @param endHyphen The start hyphen value for this class, as expected by
48 | * [TextPaint.setEndHyphenEdit].
49 | * @param justifyWidth The word spacing required to justify this line of text, as expected by
50 | * [TextPaint.setWordSpacing].
51 | * @param x The x coordinate of where to draw the line of text.
52 | * @param y The y coordinate of where to draw the line of text.
53 | * @param paint The paint to use to render this line of text.
54 | */
55 | internal class TextSegment(
56 | val buffer: String,
57 | val start: Int,
58 | val end: Int,
59 | val startHyphen: Int,
60 | val endHyphen: Int,
61 | val justifyWidth: Float,
62 | val x: Float,
63 | val y: Float,
64 | val paint: TextPaint
65 | )
66 |
67 | /**
68 | * Computes a layout to flow the specified text around the series of specified flow shape.
69 | * Each flow shape indicates on which side of the shape text should flow. The result of the
70 | * layout is stored in the supplied [flowState] structure.
71 | *
72 | * The caller is responsible for iterating over the list of txet segments to render the result
73 | * (see [TextSegment]).
74 | *
75 | * @param text The text to layout.
76 | * @param style The default style for the text.
77 | * @param size The size of the area where layout must occur. The resulting text will not
78 | * extend beyond those dimensions.
79 | * @param columns Number of columns of text to use in the given area.
80 | * @param columnSpacing Empty space between columns.
81 | * @param layoutDirection The RTL or LTR direction of the layout.
82 | * @param justification Sets the type of text justification.
83 | * @param hyphenation Sets the type of text hyphenation (only on supported API levels).
84 | * @param flowState State of the calling TextFlow.
85 | * @return A [TextFlowLayoutResult] giving information about how [text] was laid out.
86 | */
87 | internal fun layoutTextFlow(
88 | text: AnnotatedString,
89 | style: TextStyle,
90 | size: IntSize,
91 | columns: Int,
92 | columnSpacing: Float,
93 | layoutDirection: LayoutDirection,
94 | justification: TextFlowJustification,
95 | hyphenation: TextFlowHyphenation,
96 | flowState: TextFlowState
97 | ): TextFlowLayoutResult {
98 |
99 | val lineBreaker = LineBreaker.Builder()
100 | .setBreakStrategy(LineBreaker.BREAK_STRATEGY_HIGH_QUALITY)
101 | .setHyphenationFrequency(LineBreaker.HYPHENATION_FREQUENCY_FULL)
102 | .setJustificationMode(LineBreaker.JUSTIFICATION_MODE_NONE)
103 | .build()
104 | val constraints = LineBreaker.ParagraphConstraints()
105 |
106 | var columnCount = columns.coerceIn(1, Int.MAX_VALUE)
107 | var columnWidth = (size.width.toFloat() - (columns - 1) * columnSpacing) / columnCount
108 | while (columnWidth <= 0.0f && columnCount > 0) {
109 | columnCount--
110 | columnWidth = (size.width.toFloat() - (columns - 1) * columnSpacing) / columnCount
111 | }
112 |
113 | val ltr = layoutDirection == LayoutDirection.Ltr
114 | val column = RectF(
115 | if (ltr) 0.0f else size.width - columnWidth,
116 | 0.0f,
117 | if (ltr) columnWidth else size.width.toFloat(),
118 | size.height.toFloat()
119 | )
120 |
121 | val state = TextLayoutState(
122 | text,
123 | style,
124 | flowState.resolver,
125 | flowState.density
126 | )
127 |
128 | val slotFinderState = FlowSlotFinderState()
129 |
130 | for (c in 0 until columnCount) {
131 | // Cursor to indicate where to draw the next line of text
132 | var y = 0.0f
133 |
134 | while (state.hasNextParagraph) {
135 | val paragraph = state.currentParagraph
136 |
137 | val firstStyle = state.currentParagraphStyles()[0].data!!
138 | val lineHeight = state.paintForStyle(firstStyle).lineHeight
139 |
140 | // Skip empty paragraphs but advance the cursor to mark empty lines
141 | if (paragraph.isEmpty()) {
142 | y += lineHeight
143 | state.nextParagraph()
144 | continue
145 | }
146 |
147 | // We want to layout text until we've run out of characters in the current paragraph,
148 | // or we've run out of space in the layout area
149 | while (state.isInsideParagraph && y < column.height()) {
150 | // The first step is to find all the slots in which we could layout text for the
151 | // current text line. We currently assume a line of text has a fixed height in
152 | // a given paragraph.
153 | // The result is a list of rectangles deemed appropriate to place text. These
154 | // rectangles might however be too small to properly place text from the current
155 | // line and may be skipped over later.
156 | val slots = findFlowSlots(
157 | RectF(column.left, y, column.right, y + lineHeight),
158 | RectF(0.0f, y, size.width.toFloat(), y + lineHeight),
159 | flowState.shapes,
160 | slotFinderState
161 | )
162 |
163 | var ascent = 0.0f
164 | var descent = 0.0f
165 |
166 | // Remember our number of "lines" to check later if we added new ones
167 | val textSegmentCount = flowState.textSegments.size
168 |
169 | // We now need to fit as much text as possible for the current paragraph in the list
170 | // of slots we just computed
171 | for (slot in slots) {
172 | // Sets the constraints width to that of our current slot
173 | constraints.width = slot.right - slot.left
174 |
175 | // Skip empty slots
176 | if (constraints.width <= 0) continue
177 |
178 | if (constraints.width != state.lastSlotWidth) {
179 | state.paragraphOffset = state.breakOffset
180 |
181 | // We could use toCharArray() with a pre-built array and offset, but
182 | // MeasuredText.Build wants styles to cover the entire array, and
183 | // LineBreaker therefore expects to compute breaks over the entire array
184 | val charArray = paragraph.toCharArray(state.paragraphOffset)
185 | state.measuredText = MeasuredText.Builder(charArray)
186 | .appendStyleRuns(state)
187 | .hyphenation(hyphenation)
188 | .build()
189 |
190 | state.result = lineBreaker.computeLineBreaks(
191 | state.measuredText,
192 | constraints,
193 | 0
194 | )
195 |
196 | state.lastParagraphLine = 0
197 | state.lastLineOffset = 0
198 | }
199 | state.lastSlotWidth = slot.width()
200 |
201 | val result = state.result
202 | checkNotNull(result)
203 |
204 | val startHyphen = result.getStartLineHyphenEdit(state.lastParagraphLine)
205 | val endHyphen = result.getEndLineHyphenEdit(state.lastParagraphLine)
206 | val lineOffset = result.getLineBreakOffset(state.lastParagraphLine)
207 | val lineWidth = result.getLineWidth(state.lastParagraphLine)
208 |
209 | // Tells us how far to move the cursor for the next line of text
210 | val lineAscent = -result.getLineAscent(state.lastParagraphLine)
211 | val lineDescent = result.getLineDescent(state.lastParagraphLine)
212 |
213 | // Don't enqueue a new line if we'd lay it out out of bounds
214 | if (y > column.height() || (y + lineAscent + lineDescent) > column.height()) {
215 | break
216 | }
217 |
218 | // Start and end offset of the line relative to the paragraph itself
219 | val startOffset = state.paragraphOffset + state.lastLineOffset
220 | val endOffset = state.paragraphOffset + lineOffset
221 |
222 | val justifyWidth = justify(
223 | state,
224 | lineWidth,
225 | constraints.width,
226 | paragraph,
227 | startOffset,
228 | endOffset,
229 | endHyphen,
230 | justification
231 | )
232 |
233 | // We couldn't fit our text in the available slot, try the next one
234 | if (justifyWidth.isNaN()) continue
235 |
236 | // Find the first merged style that intersects our text
237 | var cursor = startOffset
238 | var styleIndex = state.mergedStyles.indexOfFirst { cursor < it.end }
239 |
240 | var x = slot.left
241 | while (cursor < endOffset) {
242 | val interval = state.mergedStyles[styleIndex]
243 | val start = max(startOffset, interval.start.toInt())
244 | val end = min(endOffset, interval.end.toInt())
245 | val segmentStyle = interval.data!!
246 |
247 | val paint = state.paintForStyle(segmentStyle)
248 |
249 | val localStartHyphen = if (start == startOffset) {
250 | startHyphen
251 | } else {
252 | Paint.START_HYPHEN_EDIT_NO_EDIT
253 | }
254 |
255 | val localEndHyphen = if (end == endOffset) {
256 | endHyphen
257 | } else {
258 | Paint.START_HYPHEN_EDIT_NO_EDIT
259 | }
260 |
261 | flowState.textSegments.add(
262 | TextSegment(
263 | paragraph,
264 | start,
265 | end,
266 | localStartHyphen,
267 | localEndHyphen,
268 | justifyWidth,
269 | x,
270 | y + lineAscent,
271 | paint
272 | )
273 | )
274 |
275 | cursor = end
276 | styleIndex++
277 |
278 | if (cursor < endOffset) {
279 | if (justifyWidth != 0.0f) {
280 | with(paint) {
281 | startHyphenEdit = localStartHyphen
282 | endHyphenEdit = localEndHyphen
283 | wordSpacing = justifyWidth
284 | }
285 | x += paint.measureText(paragraph, start, end)
286 | } else {
287 | x += state.measuredText.getWidth(
288 | start - state.paragraphOffset,
289 | end - state.paragraphOffset
290 | )
291 | }
292 | }
293 | }
294 |
295 | ascent = max(ascent, lineAscent)
296 | descent = max(descent, lineDescent)
297 |
298 | state.textHeight = max(state.textHeight, y + descent)
299 |
300 | state.breakOffset = state.paragraphOffset + lineOffset
301 | state.lastLineOffset = lineOffset
302 |
303 | state.lastParagraphLine++
304 |
305 | if (!state.isInsideParagraph) break
306 | }
307 |
308 | // If we were not able to find a suitable slot and we haven't found
309 | // our first line yet, move y forward by the default line height
310 | // so we don't loop forever
311 | y += if (
312 | textSegmentCount == flowState.textSegments.size &&
313 | ascent == 0.0f &&
314 | descent == 0.0f
315 | ) {
316 | lineHeight
317 | } else {
318 | descent + ascent
319 | }
320 | }
321 |
322 | // Reached the end of the paragraph, move to the next one
323 | if (!state.isInsideParagraph) {
324 | state.nextParagraph()
325 | }
326 |
327 | // Early exit if we have more text than area
328 | if (y >= column.height()) break
329 | }
330 |
331 | // Move to the next column
332 | column.offset((column.width() + columnSpacing) * if (ltr) 1.0f else -1.0f, 0.0f)
333 | }
334 |
335 | return TextFlowLayoutResult(state.textHeight, state.totalOffset + state.breakOffset)
336 | }
337 |
338 | /**
339 | * Append style runs to this [MeasuredText.Builder]. The style runs are provided by the
340 | * [TextLayoutState] when invoking [TextLayoutState.currentParagraphStyles].
341 | */
342 | private fun MeasuredText.Builder.appendStyleRuns(
343 | state: TextLayoutState
344 | ): MeasuredText.Builder {
345 |
346 | state.currentParagraphStyles().forEach { interval ->
347 | if (interval.end > state.paragraphOffset) {
348 | val start = max(0, (interval.start - state.paragraphOffset).toInt())
349 | val end = (interval.end - state.paragraphOffset).toInt()
350 | val style = interval.data!!
351 | appendStyleRun(state.paintForStyle(style), end - start, false)
352 | }
353 | }
354 |
355 | return this
356 | }
357 |
358 | /**
359 | * Implement justification. We only justify when we are not laying out the last
360 | * line of a paragraph (which looks horrible) or if the current line is too wide
361 | * (which could be because LineBreaker entered desperate mode).
362 | */
363 | private fun justify(
364 | state: TextLayoutState,
365 | lineWidth: Float,
366 | maxWidth: Float,
367 | paragraph: String,
368 | paragraphLastLineOffset: Int,
369 | paragraphLineOffset: Int,
370 | endHyphen: Int,
371 | justification: TextFlowJustification
372 | ): Float {
373 | var justifyWidth = 0.0f
374 |
375 | // If this is true, LineBreaker tried too hard to put text in the current slot
376 | // We'll first try to shrink the text and, if we can't, we'll skip the slot and
377 | // move on to the next one
378 | val lineTooWide = lineWidth > maxWidth
379 |
380 | if (paragraphLineOffset < paragraph.length || lineTooWide) {
381 | // Trim end spaces as needed and figure out how many stretchable spaces we
382 | // can work with to justify or fit our text in the slot
383 | val hasEndHyphen = endHyphen != 0
384 |
385 | val endOffset = if (hasEndHyphen)
386 | paragraphLineOffset
387 | else
388 | trimEndSpace(paragraph, state.paragraphOffset, paragraphLineOffset)
389 |
390 | val stretchableSpaces = countStretchableSpaces(
391 | paragraph,
392 | paragraphLastLineOffset,
393 | endOffset
394 | )
395 |
396 | // If we found stretchable spaces, we can attempt justification/line
397 | // shrinking, otherwise, and if the line is too wide, we just bail and hope
398 | // the next slot will be large enough for us to work with
399 | if (stretchableSpaces != 0) {
400 | var width = lineWidth
401 |
402 | // When the line is too wide and we don't have a hyphen, LineBreaker was
403 | // "desperate" to fit the text, so we can't use its text measurement.
404 | // Instead we measure the width ourselves so we can shrink the line with
405 | // negative word spacing
406 | if (lineTooWide && !hasEndHyphen) {
407 | width = state.measuredText.getWidth(paragraphLastLineOffset, endOffset)
408 | }
409 |
410 | // Compute the amount of spacing to give to each stretchable space
411 | // Can be positive (justification) or negative (line too wide)
412 | justifyWidth = (maxWidth - width) / stretchableSpaces
413 |
414 | // Kill justification if the user asked to, but keep line shrinking
415 | // for hyphens and desperate placement
416 | if (justification == TextFlowJustification.None && justifyWidth > 0) {
417 | justifyWidth = 0.0f
418 | }
419 | } else if (lineTooWide) {
420 | return Float.NaN
421 | }
422 | }
423 | return justifyWidth
424 | }
425 |
426 | // Comparator used to sort the results of an interval query against the styleIntervals
427 | // tree. The tree doesn't preserve ordering and this comparator puts the intervals/ranges
428 | // back in the order provided by AnnotatedString
429 | private val StyleComparator = Comparator { s1: Interval, s2: Interval ->
430 | val start = (s1.start - s2.start).toInt()
431 | if (start < 0.0f) -1
432 | else if (start == 0) (s2.end - s1.end).toInt()
433 | else 1
434 | }
435 |
436 | private class TextLayoutState(
437 | val text: AnnotatedString,
438 | val textStyle: TextStyle,
439 | private val resolver: FontFamily.Resolver,
440 | private val density: Density
441 | ) {
442 | // List of all the paragraphs in our original text
443 | private val paragraphs = text.split('\n')
444 |
445 | // Summed list of offsets: each entry corresponds to the starting offset of the corresponding
446 | // paragraph in the original text
447 | private var _paragraphStartOffsets = paragraphs.scan(0) { accumulator, element ->
448 | accumulator + element.length + 1
449 | }
450 |
451 | // Start offset in the original text of the current paragraph
452 | private inline val currentParagraphStartOffset: Int
453 | get() = _paragraphStartOffsets[_currentParagraph]
454 |
455 | // Index of the current paragraph in our list of paragraphs
456 | private var _currentParagraph = 0
457 | // Current paragraph as a String
458 | inline val currentParagraph: String
459 | get() = paragraphs[_currentParagraph]
460 |
461 | // Returns true if we have more paragraphs to process, false otherwise
462 | inline val hasNextParagraph: Boolean
463 | get() = _currentParagraph < paragraphs.size
464 |
465 | // Returns true if we can still find break points inside the current paragraph
466 | inline val isInsideParagraph: Boolean
467 | get() = breakOffset < currentParagraph.length
468 |
469 | // Width of the last slot we used for fitting, if we attempt to fit inside a slot of the
470 | // same width as last time in the same paragraph, we can skip re-measuring text and line
471 | // breaks to speed up layout
472 | var lastSlotWidth = Float.NaN
473 |
474 | // Last break offset in the current paragraph. This offset is relative to the beginning
475 | // of [subtext].
476 | var lastLineOffset = 0
477 |
478 | // Current offset inside the paragraph. Because of how LineBreaker works, we sometimes
479 | // re-measure the text by using a substring of the paragraph. This tells us where
480 | // that substring is in the paragraph. This only gets updated when we re-measure part
481 | // of the paragraph.
482 | var paragraphOffset = 0
483 |
484 | // Tracks the offset of the last break in the current paragraph, relative to the beginning
485 | // of the paragraph. This is where we want to start reading text for our next measurement.
486 | // This offset is updated every time we lay out a chunk of text.
487 | var breakOffset = 0
488 |
489 | // Last line we laid out with the current measure of the current paragraph.
490 | var lastParagraphLine = 0
491 |
492 | // Interval tree of all the styles found in the source annotated string
493 | // This tree allows us to quickly lookup the styles for a given paragraph
494 | val styleIntervals = IntervalTree().apply {
495 | text.spanStyles.forEach {
496 | this += Interval(it.start.toFloat(), it.end.toFloat(), it.item)
497 | }
498 | }
499 |
500 | // List of merged styles for the current paragraph. Styles can overlap in the source
501 | // annotated string. To make lookups easier, we merge the styles ahead of time when
502 | // consuming a new paragraph
503 | val mergedStyles = ArrayList>(16)
504 | // Temporary list used to query paragraph styles
505 | val stylesQuery = ArrayList>(16)
506 |
507 | // Cache of paints used to measurement and drawing
508 | val paints = mutableMapOf()
509 |
510 | // Used to measure and break text, initialized here to avoid null checks
511 | var measuredText = MeasuredText.Builder(CharArray(1))
512 | .appendStyleRun(Paint(), 1, false)
513 | .build()
514 | var result: LineBreaker.Result? = null
515 |
516 | // For TextFlowLayoutResult
517 | var textHeight = 0.0f
518 | var totalOffset = 0
519 |
520 | // Moves the internal state to the next paragraph in the list
521 | fun nextParagraph() {
522 | _currentParagraph++
523 | mergedStyles.clear()
524 | totalOffset += breakOffset + 1
525 | breakOffset = 0
526 | lastSlotWidth = Float.NaN
527 | }
528 |
529 | // Returns the list of ranged styles for the current paragraph. Invoking this function
530 | // after calling nextParagraph() triggers a complete computation of all the merged
531 | // styles for the paragraph
532 | fun currentParagraphStyles(): ArrayList> {
533 | // The code below guarantees that the list of merged styles always contains at least
534 | // 1 style. When the list is empty it means we haven't built it yet
535 | if (mergedStyles.isNotEmpty()) {
536 | return mergedStyles
537 | }
538 |
539 | val paragraph = currentParagraph
540 | val offset = currentParagraphStartOffset
541 | val fullParagraph = paragraph.isNotEmpty()
542 |
543 | val searchInternal = Interval(
544 | offset.toFloat(),
545 | (offset + paragraph.length).toFloat()
546 | )
547 |
548 | stylesQuery.clear()
549 | styleIntervals.findOverlaps(searchInternal, stylesQuery).sortedWith(StyleComparator)
550 |
551 | mergedStyles.add(Interval(0.0f, paragraph.length.toFloat(), textStyle))
552 |
553 | // This loop takes all the "spans" (ranged styles) from the annotated string and merges
554 | // and splits them so we are left with a continuous list of styled ranges. The resulting
555 | // list gives the exact style for all the offsets in a given annotated string (at least
556 | // for our current paragraph). This allows to trivially fetch the style at any given
557 | // index or range.
558 | val styleCount = stylesQuery.size
559 | for (j in 0 until styleCount) {
560 | val style = stylesQuery[j]
561 | val start = max(style.start - offset, 0.0f)
562 | val end = min(style.end - offset, paragraph.length.toFloat())
563 |
564 | if (start == end && fullParagraph) continue
565 |
566 | for (i in 0 until mergedStyles.size) {
567 | val merged = mergedStyles[i]
568 |
569 | if (merged.start == merged.end && fullParagraph) continue
570 |
571 | val styleData = style.data!!
572 | val mergedData = merged.data!!
573 |
574 | if (start <= merged.start && end >= merged.end) {
575 | // The merged style is contained withing the current style
576 | mergedStyles[i] = Interval(merged.start, merged.end, mergedData.merge(styleData))
577 | } else if (start >= merged.start && end <= merged.end) {
578 | // The current style is contained withing the merged style
579 | if (end != merged.end) {
580 | mergedStyles.add(i + 1, Interval(end, merged.end, mergedData))
581 | }
582 | mergedStyles.add(i + 1, Interval(start, end, mergedData.merge(styleData)))
583 | if (merged.start != start) {
584 | mergedStyles[i] = Interval(merged.start, start, mergedData)
585 | } else {
586 | mergedStyles.removeAt(i)
587 | }
588 | } else if (start < merged.start && end > merged.start) {
589 | // The current style right-intersects with the merged style
590 | mergedStyles[i] = Interval(merged.start, end, mergedData.merge(styleData))
591 | mergedStyles.add(
592 | i + 1,
593 | Interval(start, merged.start, mergedData)
594 | )
595 | } else if (start < merged.end && end > merged.end) {
596 | // The current style left-intersects with the merged style
597 | mergedStyles[i] = Interval(merged.start, start, mergedData)
598 | mergedStyles.add(
599 | i + 1,
600 | Interval(start, merged.end, mergedData.merge(styleData))
601 | )
602 | }
603 | }
604 | }
605 |
606 | return mergedStyles
607 | }
608 |
609 | // Returns a paint for the specified style. The paint is cached as needed
610 | fun paintForStyle(style: TextStyle) = paints.computeIfAbsent(style) {
611 | createTextPaint(resolver, style, density)
612 | }
613 | }
614 |
615 | /**
616 | * Indicates whether the specified character should be considered white space at the end of
617 | * a line of text. These characters can be safely ignored for measurement and layout.
618 | */
619 | private fun isLineEndSpace(c: Char) =
620 | c == ' ' || c == '\t' || c == Char(0x1680) ||
621 | (Char(0x2000) <= c && c <= Char(0x200A) && c != Char(0x2007)) ||
622 | c == Char(0x205F) || c == Char(0x3000)
623 |
624 | /**
625 | * Count the number of stretchable spaces between [start] and [end] in the specified string.
626 | * Only the Unicode value 0x0020 qualifies, as other spaces must be used as-is (for instance
627 | * no-break spaces, em spaces, etc.).
628 | */
629 | @Suppress("SameParameterValue")
630 | private fun countStretchableSpaces(text: String, start: Int, end: Int): Int {
631 | var count = 0
632 | for (i in start until end) {
633 | if (text[i] == Char(0x0020)) {
634 | count++
635 | }
636 | }
637 | return count
638 | }
639 |
640 | /**
641 | * Returns the offset of the last non-whitespace in a given string, starting from [lineEndOffset].
642 | */
643 | private fun trimEndSpace(text: String, startOffset: Int, lineEndOffset: Int): Int {
644 | var endOffset = lineEndOffset
645 | while (endOffset > startOffset && isLineEndSpace(text[endOffset - 1])) {
646 | endOffset--
647 | }
648 | return endOffset
649 | }
650 |
651 | private fun MeasuredText.Builder.hyphenation(
652 | hyphenation: TextFlowHyphenation
653 | ): MeasuredText.Builder {
654 | if (Build.VERSION.SDK_INT >= 33) {
655 | MeasuredTextHelper.hyphenation(this, hyphenation)
656 | }
657 | return this
658 | }
659 |
--------------------------------------------------------------------------------
/combo-breaker/src/main/java/dev/romainguy/text/combobreaker/BasicTextFlow.kt:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2022 Romain Guy
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | package dev.romainguy.text.combobreaker
18 |
19 | import android.graphics.Paint
20 | import android.graphics.RectF
21 | import androidx.compose.foundation.gestures.detectDragGestures
22 | import androidx.compose.foundation.layout.LayoutScopeMarker
23 | import androidx.compose.runtime.Composable
24 | import androidx.compose.runtime.Immutable
25 | import androidx.compose.runtime.Stable
26 | import androidx.compose.runtime.derivedStateOf
27 | import androidx.compose.runtime.getValue
28 | import androidx.compose.runtime.mutableStateOf
29 | import androidx.compose.runtime.remember
30 | import androidx.compose.runtime.setValue
31 | import androidx.compose.ui.Alignment
32 | import androidx.compose.ui.Modifier
33 | import androidx.compose.ui.draw.drawBehind
34 | import androidx.compose.ui.draw.drawWithCache
35 | import androidx.compose.ui.geometry.Offset
36 | import androidx.compose.ui.geometry.Rect
37 | import androidx.compose.ui.geometry.Size
38 | import androidx.compose.ui.graphics.Brush
39 | import androidx.compose.ui.graphics.Color
40 | import androidx.compose.ui.graphics.Path
41 | import androidx.compose.ui.graphics.TileMode
42 | import androidx.compose.ui.graphics.asAndroidPath
43 | import androidx.compose.ui.graphics.drawscope.ContentDrawScope
44 | import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
45 | import androidx.compose.ui.graphics.nativeCanvas
46 | import androidx.compose.ui.input.pointer.pointerInput
47 | import androidx.compose.ui.layout.Layout
48 | import androidx.compose.ui.layout.LayoutCoordinates
49 | import androidx.compose.ui.layout.Measurable
50 | import androidx.compose.ui.layout.MeasurePolicy
51 | import androidx.compose.ui.layout.OnPlacedModifier
52 | import androidx.compose.ui.layout.ParentDataModifier
53 | import androidx.compose.ui.layout.Placeable
54 | import androidx.compose.ui.layout.positionInParent
55 | import androidx.compose.ui.platform.LocalContext
56 | import androidx.compose.ui.platform.LocalDensity
57 | import androidx.compose.ui.text.AnnotatedString
58 | import androidx.compose.ui.text.TextStyle
59 | import androidx.compose.ui.text.font.FontFamily
60 | import androidx.compose.ui.text.font.createFontFamilyResolver
61 | import androidx.compose.ui.unit.Constraints
62 | import androidx.compose.ui.unit.Density
63 | import androidx.compose.ui.unit.Dp
64 | import androidx.compose.ui.unit.IntOffset
65 | import androidx.compose.ui.unit.IntSize
66 | import androidx.compose.ui.unit.LayoutDirection
67 | import androidx.compose.ui.unit.dp
68 | import androidx.compose.ui.unit.toOffset
69 | import androidx.compose.ui.unit.toSize
70 | import dev.romainguy.text.combobreaker.FlowType.None
71 | import dev.romainguy.text.combobreaker.FlowType.Outside
72 | import dev.romainguy.text.combobreaker.FlowType.OutsideEnd
73 | import dev.romainguy.text.combobreaker.FlowType.OutsideLeft
74 | import dev.romainguy.text.combobreaker.FlowType.OutsideRight
75 | import dev.romainguy.text.combobreaker.FlowType.OutsideStart
76 | import kotlin.math.max
77 |
78 | /**
79 | * A [FlowType] is associated to a shape (defined by a [Path]) and describes how the text
80 | * should behave with respect to that shape. The following behaviors are supported:
81 | *
82 | * - [OutsideLeft]: text will flow only to the left of the shape. The shape will act as
83 | * a barrier on the right, preventing any flow on that side (even if another shape
84 | * permitting flow in that region is present).
85 | * - [OutsideRight]: text will flow only to the right of the shape. The shape will act as
86 | * a barrier on the left, preventing any flow on that side (even if another shape
87 | * permitting flow in that region is present).
88 | * - [Outside]: text will flow on both sides of the shape.
89 | * - [OutsideStart]: text will flow either to the left or right of the shape, depending
90 | * on the layout direction. If the direction is set to [LayoutDirection.Ltr], text will
91 | * flow left, right otherwise.
92 | * - [OutsideEnd]: text will flow either to the left or right of the shape, depending
93 | * on the layout direction. If the direction is set to [LayoutDirection.Ltr], text will
94 | * flow right, left otherwise.
95 | * - [None]: the associate shape is entirely ignored for text flow. This is useful to
96 | * keep an overlay above the text for instance.
97 | */
98 | enum class FlowType(private val bits: Int) {
99 | OutsideLeft(1),
100 | OutsideRight(2),
101 | Outside(3),
102 | OutsideStart(-1),
103 | OutsideEnd(-2),
104 | None(0);
105 |
106 | /**
107 | * Returns true if this type permits a left flow (including with [Outside]).
108 | */
109 | internal val isLeftFlow: Boolean
110 | get() { return (bits and 0x1) != 0 }
111 |
112 | /**
113 | * Returns true if this type permits a right flow (including with [Outside]).
114 | */
115 | internal val isRightFlow: Boolean
116 | get() { return (bits and 0x2) != 0 }
117 |
118 | /**
119 | * Returns a new [FlowType] set to either [OutsideLeft], [OutsideRight], [Outside],
120 | * or [None], according to this type and the specified [LayoutDirection]. The
121 | * return value is never one of [OutsideStart] or [OutsideEnd].
122 | */
123 | internal fun resolve(direction: LayoutDirection) = when (direction) {
124 | LayoutDirection.Ltr -> when (this) {
125 | OutsideStart -> OutsideLeft
126 | OutsideEnd -> OutsideRight
127 | else -> this
128 | }
129 | LayoutDirection.Rtl -> when (this) {
130 | OutsideLeft -> OutsideLeft
131 | OutsideRight -> OutsideRight
132 | Outside -> Outside
133 | OutsideStart -> OutsideRight
134 | OutsideEnd -> OutsideLeft
135 | None -> None
136 | }
137 | }
138 | }
139 |
140 | /**
141 | * Controls the behavior of text justification in a [BasicTextFlow].
142 | */
143 | enum class TextFlowJustification {
144 | /** Turns off text justification. */
145 | None,
146 | /** Turns on text justification. */
147 | Auto
148 | }
149 |
150 | /**
151 | * Controls the behavior of text hyphenation in a [BasicTextFlow]. Hyphenation will only work on
152 | * API level 33 and above.
153 | */
154 | enum class TextFlowHyphenation {
155 | /** No text hyphenation. */
156 | None,
157 | /** Automatic text hyphenation. */
158 | Auto
159 | }
160 |
161 | /**
162 | * Holds the result of a layout pass performed by [layoutTextFlow].
163 | *
164 | * @param height The total height of the text after layout.
165 | * @param lastOffset Offset inside the source text marking the point where the layout stopped.
166 | * Any text after that offset was not laid out.
167 | */
168 | data class TextFlowLayoutResult(val height: Float, val lastOffset: Int)
169 |
170 | /**
171 | * A layout composable with [content] that can flow [text] around the shapes defined by the
172 | * elements of [content].
173 | *
174 | * The specified text will be laid out inside a number of columns defined by the [columns]
175 | * parameter, each separated by white space defined by [columnSpacing]. All the children
176 | * from [content] define *flow shapes* that text will flow around. How text flows around any
177 | * given shape is defined by the [FlowType] of that element/shape. The flow shape and its
178 | * flow type can be defined for a given element by using the [TextFlowScope.flowShape] modifier.
179 | *
180 | * The default flow shape of each element is a rectangle of the same dimensions as the element
181 | * itself, with a flow type set to [FlowType.Outside].
182 | *
183 | * The [BasicTextFlow] will size itself to fit the [content], subject to the incoming constraints.
184 | * When children are smaller than the parent, by default they will be positioned inside
185 | * the [BasicTextFlow] according to the [contentAlignment]. For individually specifying the
186 | * alignments of the children layouts, use the [TextFlowScope.align] modifier.
187 | *
188 | * Text justification can be controlled with the [justification] parameter, but it is strongly
189 | * recommended to leave it on to provide balanced flow around non-rectangular shapes with a flow
190 | * type set to [FlowType.Outside].
191 | *
192 | * Text hyphenation can be controlled with the [hyphenation] parameter, but will only work on
193 | * API level that support hyphenation control (API 33+). It is also recommended to keep hyphenation
194 | * turned on to provide more balanced results.
195 | *
196 | * By default, the content will be measured without the [BasicTextFlow]'s incoming min constraints,
197 | * unless [propagateMinConstraints] is `true`. As an example, setting [propagateMinConstraints] to
198 | * `true` can be useful when the [BasicTextFlow] has content on which modifiers cannot be specified
199 | * directly and setting a min size on the content of the [BasicTextFlow] is needed. If
200 | * [propagateMinConstraints] is set to `true`, the min size set on the [BasicTextFlow] will also be
201 | * applied to the content, whereas otherwise the min size will only apply to the [BasicTextFlow].
202 | *
203 | * When the content has more than one layout child the layout children will be stacked one
204 | * on top of the other (positioned as explained above) in the composition order.
205 | *
206 | * @param text The text to layout around the shapes defined by the content's elements.
207 | * @param modifier The modifier to be applied to the layout.
208 | * @param style The default text style to apply to [text].
209 | * @param justification Sets the type of text justification.
210 | * @param hyphenation Sets the type of text hyphenation (only on supported API levels).
211 | * @param columns The desired number of columns to layout [text] with.
212 | * @param columnSpacing The amount of space between two adjacent columns.
213 | * @param onTextFlowLayoutResult Will be invoked with information about the text layout.
214 | * @param contentAlignment The default alignment inside the layout.
215 | * @param propagateMinConstraints Whether the incoming min constraints should be passed to content.
216 | * @param debugOverlay Used for debugging only.
217 | * @param content The content of the [BasicTextFlow]. Each element in the content defines a flow
218 | * shape that is taken into account to layout [text].
219 | */
220 | @Composable
221 | fun BasicTextFlow(
222 | text: String,
223 | style: TextStyle,
224 | modifier: Modifier = Modifier,
225 | justification: TextFlowJustification = TextFlowJustification.None,
226 | hyphenation: TextFlowHyphenation = TextFlowHyphenation.Auto,
227 | columns: Int = 1,
228 | columnSpacing: Dp = 16.dp,
229 | onTextFlowLayoutResult: (result: TextFlowLayoutResult) -> Unit = { },
230 | contentAlignment: Alignment = Alignment.TopStart,
231 | propagateMinConstraints: Boolean = false,
232 | debugOverlay: Boolean = false,
233 | content: @Composable TextFlowScope.() -> Unit
234 | ) {
235 | val annotatedText by remember(text, style) {
236 | derivedStateOf { AnnotatedString(text, style.toSpanStyle()) }
237 | }
238 |
239 | BasicTextFlow(
240 | annotatedText,
241 | style,
242 | modifier,
243 | justification,
244 | hyphenation,
245 | columns,
246 | columnSpacing,
247 | onTextFlowLayoutResult,
248 | contentAlignment,
249 | propagateMinConstraints,
250 | debugOverlay,
251 | content
252 | )
253 | }
254 |
255 | /**
256 | * A layout composable with [content] that can flow [text] around the shapes defined by the
257 | * elements of [content].
258 | *
259 | * The specified text will be laid out inside a number of columns defined by the [columns]
260 | * parameter, each separated by white space defined by [columnSpacing]. All the children
261 | * from [content] define *flow shapes* that text will flow around. How text flows around any
262 | * given shape is defined by the [FlowType] of that element/shape. The flow shape and its
263 | * flow type can be defined for a given element by using the [TextFlowScope.flowShape] modifier.
264 | *
265 | * The default flow shape of each element is a rectangle of the same dimensions as the element
266 | * itself, with a flow type set to [FlowType.Outside].
267 | *
268 | * The [BasicTextFlow] will size itself to fit the [content], subject to the incoming constraints.
269 | * When children are smaller than the parent, by default they will be positioned inside
270 | * the [BasicTextFlow] according to the [contentAlignment]. For individually specifying the
271 | * alignments of the children layouts, use the [TextFlowScope.align] modifier.
272 | *
273 | * Text justification can be controlled with the [justification] parameter, but it is strongly
274 | * recommended to leave it on to provide balanced flow around non-rectangular shapes with a flow
275 | * type set to [FlowType.Outside].
276 | *
277 | * Text hyphenation can be controlled with the [hyphenation] parameter, but will only work on
278 | * API level that support hyphenation control (API 33+). It is also recommended to keep hyphenation
279 | * turned on to provide more balanced results.
280 | *
281 | * By default, the content will be measured without the [BasicTextFlow]'s incoming min constraints,
282 | * unless [propagateMinConstraints] is `true`. As an example, setting [propagateMinConstraints] to
283 | * `true` can be useful when the [BasicTextFlow] has content on which modifiers cannot be specified
284 | * directly and setting a min size on the content of the [BasicTextFlow] is needed. If
285 | * [propagateMinConstraints] is set to `true`, the min size set on the [BasicTextFlow] will also be
286 | * applied to the content, whereas otherwise the min size will only apply to the [BasicTextFlow].
287 | *
288 | * When the content has more than one layout child the layout children will be stacked one
289 | * on top of the other (positioned as explained above) in the composition order.
290 | *
291 | * @param text The text to layout around the shapes defined by the content's elements.
292 | * @param modifier The modifier to be applied to the layout.
293 | * @param style The default text style to apply to [text].
294 | * @param justification Sets the type of text justification.
295 | * @param hyphenation Sets the type of text hyphenation (only on supported API levels).
296 | * @param columns The desired number of columns to layout [text] with.
297 | * @param columnSpacing The amount of space between two adjacent columns.
298 | * @param onTextFlowLayoutResult Will be invoked with information about the text layout.
299 | * @param contentAlignment The default alignment inside the layout.
300 | * @param propagateMinConstraints Whether the incoming min constraints should be passed to content.
301 | * @param debugOverlay Used for debugging only.
302 | * @param content The content of the [BasicTextFlow]. Each element in the content defines a flow
303 | * shape that is taken into account to layout [text].
304 | */
305 | @Composable
306 | fun BasicTextFlow(
307 | text: AnnotatedString,
308 | style: TextStyle,
309 | modifier: Modifier = Modifier,
310 | justification: TextFlowJustification = TextFlowJustification.None,
311 | hyphenation: TextFlowHyphenation = TextFlowHyphenation.Auto,
312 | columns: Int = 1,
313 | columnSpacing: Dp = 16.dp,
314 | onTextFlowLayoutResult: (result: TextFlowLayoutResult) -> Unit = { },
315 | contentAlignment: Alignment = Alignment.TopStart,
316 | propagateMinConstraints: Boolean = false,
317 | debugOverlay: Boolean = false,
318 | content: @Composable TextFlowScope.() -> Unit
319 | ) {
320 | val context = LocalContext.current
321 | val density = LocalDensity.current
322 |
323 | val state = remember { TextFlowState(createFontFamilyResolver(context), density) }
324 |
325 | var debugLinePosition by remember { mutableStateOf(Float.NaN) }
326 |
327 | val measurePolicy = MeasurePolicy { measurables, constraints ->
328 | val contentConstraints = if (propagateMinConstraints) {
329 | constraints
330 | } else {
331 | constraints.copy(minWidth = 0, minHeight = 0)
332 | }
333 |
334 | val placeables = arrayOfNulls(measurables.size)
335 |
336 | state.shapes.clear()
337 | state.shapes.ensureCapacity(measurables.size)
338 |
339 | var hasMatchParentSizeChildren = false
340 | var selfWidth = constraints.minWidth
341 | var selfHeight = constraints.minHeight
342 |
343 | measurables.fastForEachIndexed { index, measurable ->
344 | if (!measurable.matchesParentSize) {
345 | val placeable = measurable.measure(contentConstraints)
346 | placeables[index] = placeable
347 | selfWidth = max(selfWidth, placeable.width)
348 | selfHeight = max(selfHeight, placeable.height)
349 | } else {
350 | hasMatchParentSizeChildren = true
351 | }
352 | }
353 |
354 | if (hasMatchParentSizeChildren) {
355 | val matchParentSizeConstraints = Constraints(
356 | minWidth = if (selfWidth != Constraints.Infinity) selfWidth else 0,
357 | minHeight = if (selfHeight != Constraints.Infinity) selfHeight else 0,
358 | maxWidth = selfWidth,
359 | maxHeight = selfHeight
360 | )
361 | measurables.fastForEachIndexed { index, measurable ->
362 | if (measurable.matchesParentSize) {
363 | placeables[index] = measurable.measure(matchParentSizeConstraints)
364 | }
365 | }
366 | }
367 |
368 | val selfSize = IntSize(selfWidth, selfHeight)
369 |
370 | layout(selfWidth, selfHeight) {
371 | val clip = Path().apply {
372 | addRect(Rect(0.0f, 0.0f, selfWidth.toFloat(), selfHeight.toFloat()))
373 | }
374 |
375 | placeables.forEachIndexed { index, placeable ->
376 | placeable as Placeable
377 |
378 | val measurable = measurables[index]
379 | val size = IntSize(placeable.width, placeable.height)
380 |
381 | val position = placeElement(
382 | placeable,
383 | measurable,
384 | size,
385 | layoutDirection,
386 | contentAlignment,
387 | selfSize
388 | )
389 |
390 | buildFlowShapes(
391 | measurable,
392 | position.toOffset(),
393 | size,
394 | selfSize,
395 | clip,
396 | state.shapes,
397 | state.density,
398 | layoutDirection
399 | )
400 | }
401 |
402 | state.textSegments.clear()
403 |
404 | val result = layoutTextFlow(
405 | text,
406 | style,
407 | selfSize,
408 | columns,
409 | columnSpacing.toPx(),
410 | layoutDirection,
411 | justification,
412 | hyphenation,
413 | state
414 | )
415 |
416 | onTextFlowLayoutResult(result)
417 |
418 | // We don't need to keep all this data when the overlay isn't present
419 | if (!debugOverlay) state.shapes.clear()
420 | }
421 | }
422 |
423 | Layout(
424 | content = { TextFlowScopeInstance.content() },
425 | measurePolicy = measurePolicy,
426 | modifier = modifier
427 | .drawBehind {
428 | drawIntoCanvas {
429 | val c = it.nativeCanvas
430 | val textSegmentCount = state.textSegments.size
431 |
432 | for (i in 0 until textSegmentCount) {
433 | val segment = state.textSegments[i]
434 |
435 | with(segment) {
436 | paint.startHyphenEdit = startHyphen
437 | paint.endHyphenEdit = endHyphen
438 | paint.wordSpacing = justifyWidth
439 |
440 | c.drawText(buffer, start, end, x, y, paint)
441 | }
442 | }
443 | }
444 | }
445 | // Debug code
446 | .thenIf(debugOverlay) {
447 | drawWithCache {
448 | val stripeFill = debugStripeFill()
449 | val paint = createTextPaint(state.resolver, style, state.density)
450 |
451 | val lineHeight = paint.fontMetrics.descent - paint.fontMetrics.ascent
452 | val y = debugLinePosition
453 | val y1 = y - lineHeight / 2.0f
454 | val y2 = y + lineHeight / 2.0f
455 |
456 | val spacing = columnSpacing.toPx()
457 | var columnCount = columns.coerceIn(1, Int.MAX_VALUE)
458 | var columnWidth = (size.width - (columns - 1) * spacing) / columnCount
459 | while (columnWidth <= 0.0f && columnCount > 0) {
460 | columnCount--
461 | columnWidth = (size.width - (columns - 1) * spacing) / columnCount
462 | }
463 |
464 | val column = RectF(0.0f, y1, columnWidth, y2)
465 | val container = RectF(0.0f, y1, size.width, y2)
466 |
467 | val slots = mutableListOf()
468 | val results = mutableListOf>()
469 |
470 | for (c in 0 until columnCount) {
471 | val columnSlots = findFlowSlots(
472 | column,
473 | container,
474 | state.shapes,
475 | results = results
476 | )
477 | slots.addAll(columnSlots)
478 | column.offset(columnWidth + spacing, 0.0f)
479 | }
480 |
481 | onDrawWithContent {
482 | drawContent()
483 |
484 | if (debugLinePosition.isFinite() && y2 >= 0.0f && y1 <= size.height) {
485 | drawDebugInfo(y1, y2, state.shapes, results, slots, stripeFill)
486 | }
487 | }
488 | }
489 | .pointerInput(Unit) {
490 | detectDragGestures { change, _ ->
491 | debugLinePosition = change.position.y
492 | }
493 | }
494 | }
495 | )
496 | }
497 |
498 | @Stable
499 | internal class TextFlowState(
500 | val resolver: FontFamily.Resolver,
501 | val density: Density
502 | ) {
503 | val textSegments: ArrayList = ArrayList()
504 | val shapes: ArrayList = ArrayList()
505 | }
506 |
507 | private fun buildFlowShapes(
508 | measurable: Measurable,
509 | elementPosition: Offset,
510 | size: IntSize,
511 | boxSize: IntSize,
512 | clip: Path,
513 | flowShapes: ArrayList,
514 | density: Density,
515 | layoutDirection: LayoutDirection
516 | ) {
517 | val textFlowData = measurable.textFlowData ?: DefaultTextFlowParentData
518 |
519 | // We ignore flow shapes marked "None". We could run all the code below and it
520 | // would work just fine since findFlowSlots() will do the right thing, but
521 | // it would be expensive and wasteful so let's not do that
522 | if (textFlowData.flowType == None) {
523 | return
524 | }
525 |
526 | val position = if (textFlowData.position == Offset.Unspecified) {
527 | elementPosition
528 | } else {
529 | textFlowData.position
530 | }
531 |
532 | val flowType = textFlowData.flowType.resolve(layoutDirection)
533 | val margin = with (density) { textFlowData.margin.toPx() }
534 |
535 | val sourcePaths = textFlowData.flowShapes(size, boxSize)
536 | if (sourcePaths.isEmpty()) {
537 | val path = Path()
538 | path.addRect(Rect(position, size.toSize()))
539 | expandAndClipPath(path, margin, clip)
540 | flowShapes += FlowShape(path, flowType)
541 | } else {
542 | for (sourcePath in sourcePaths) {
543 | val path = Path()
544 | path.addPath(sourcePath, position)
545 | expandAndClipPath(path, margin, clip)
546 | flowShapes += FlowShape(path, flowType)
547 | }
548 | }
549 | }
550 |
551 | private fun expandAndClipPath(path: Path, margin: Float, clip: Path) {
552 | if (margin > 0.0f) {
553 | // Note: see comment below
554 | val androidPath = path.asAndroidPath()
555 | Paint().apply {
556 | style = Paint.Style.FILL_AND_STROKE
557 | strokeWidth = margin * 2.0f
558 | }.getFillPath(androidPath, androidPath)
559 | }
560 |
561 | // TODO: Our layout algorithm does not need to intersect the path with the larger
562 | // containment area, but this has the nice side effect of cleaning up paths tremendously
563 | // when they've been expanded via getFillPath() above. This dramatically reduces the
564 | // number of segments and cleans up self overlaps. It is however quite expensive so
565 | // we should try to get rid of it by getting rid of getFillPath() and finding another
566 | // way to create margins
567 | path
568 | .asAndroidPath()
569 | .op(clip.asAndroidPath(), android.graphics.Path.Op.INTERSECT)
570 | }
571 |
572 | private fun Placeable.PlacementScope.placeElement(
573 | placeable: Placeable,
574 | measurable: Measurable,
575 | size: IntSize,
576 | layoutDirection: LayoutDirection,
577 | alignment: Alignment,
578 | boxSize: IntSize
579 | ): IntOffset {
580 | val childAlignment = measurable.textFlowData?.alignment ?: alignment
581 | val position = childAlignment.align(
582 | size,
583 | boxSize,
584 | layoutDirection
585 | )
586 | placeable.place(position)
587 | return position
588 | }
589 |
590 | private fun ContentDrawScope.drawDebugInfo(
591 | y1: Float,
592 | y2: Float,
593 | flowShapes: ArrayList,
594 | intervals: MutableList>,
595 | slots: List,
596 | stripeFill: Brush
597 | ) {
598 | for (flowShape in flowShapes) {
599 | drawPath(flowShape.path, stripeFill)
600 | }
601 |
602 | intervals.forEach { interval ->
603 | val segment = interval.data
604 | if (segment != null) {
605 | drawLine(
606 | color = DebugColors.SegmentColor,
607 | start = Offset(segment.x0, segment.y0),
608 | end = Offset(segment.x1, segment.y1),
609 | strokeWidth = 3.0f
610 | )
611 | }
612 | }
613 |
614 | drawRect(
615 | color = DebugColors.LineFill,
616 | topLeft = Offset(0.0f, y1),
617 | size = Size(size.width, y2 - y1)
618 | )
619 |
620 | for (slot in slots) {
621 | if (!slot.isEmpty) {
622 | drawRect(
623 | color = DebugColors.SecondaryLineFill,
624 | topLeft = slot.toOffset(),
625 | size = slot.toSize()
626 | )
627 | }
628 | }
629 |
630 | drawLine(
631 | color = DebugColors.LineBorder,
632 | start = Offset(0.0f, y1),
633 | end = Offset(size.width, y1),
634 | strokeWidth = 3.0f
635 | )
636 |
637 | drawLine(
638 | color = DebugColors.LineBorder,
639 | start = Offset(0.0f, y2),
640 | end = Offset(size.width, y2),
641 | strokeWidth = 3.0f
642 | )
643 | }
644 |
645 | /**
646 | * Lambda type used by [TextFlowScope.flowShape] to compute a flow shape defined as a [Path].
647 | *
648 | * The two parameters are:
649 | * - `size` The size of the element the flowShape modifier is applied to.
650 | * - `textFlowSize` The size of the parent [BasicTextFlow] container.
651 | */
652 | typealias FlowShapeProvider = (size: IntSize, textFlowSize: IntSize) -> Path?
653 |
654 | /**
655 | * Lambda type used by [TextFlowScope.flowShape] to compute a list of flow shapes defined as [Path]
656 | * instances.
657 | *
658 | * The two parameters are:
659 | * - `size` The size of the element the flowShape modifier is applied to.
660 | * - `textFlowSize` The size of the parent [BasicTextFlow] container.
661 | */
662 | typealias FlowShapeListProvider = (size: IntSize, textFlowSize: IntSize) -> List
663 |
664 | /**
665 | * A [TextFlowScope] provides a scope for the children of [BasicTextFlow].
666 | */
667 | @LayoutScopeMarker
668 | @Immutable
669 | interface TextFlowScope {
670 | /**
671 | * Pull the content element to a specific [Alignment] within the [BasicTextFlow]. This
672 | * alignment will have priority over the [BasicTextFlow]'s `alignment` parameter.
673 | */
674 | @Stable
675 | fun Modifier.align(alignment: Alignment): Modifier
676 |
677 | /**
678 | * Size the element to match the size of the [BasicTextFlow] after all other content
679 | * elements have been measured.
680 | *
681 | * The element using this modifier does not take part in defining the size of the
682 | * [BasicTextFlow]. Instead, it matches the size of the [BasicTextFlow] after all other
683 | * children (not using `matchParentSize()` modifier) have been measured to obtain the
684 | * [BasicTextFlow]'s size.
685 | */
686 | @Stable
687 | fun Modifier.matchParentSize(): Modifier
688 |
689 | /**
690 | * Sets the shape used to flow text around this element.
691 | *
692 | * @param flowType Defines how text flows around this element, see [FlowType].
693 | * @param margin The extra margin to add around this element for text flow.
694 | * @param flowShape A [Path] defining the shape used to flow text around this element. If
695 | * set to null, a rectangle of the dimensions of this element will be used by default.
696 | */
697 | @Stable
698 | fun Modifier.flowShape(
699 | flowType: FlowType = Outside,
700 | margin: Dp = 0.dp,
701 | flowShape: Path? = null
702 | ): Modifier
703 |
704 | /**
705 | * Sets the shapes used to flow text around this element.
706 | *
707 | * @param flowType Defines how text flows around this element, see [FlowType].
708 | * @param margin The extra margin to add around this element for text flow.
709 | * @param flowShapes A list of [Path] defining the shapes used to flow text around this element.
710 | * If the list is empty, a rectangle of the dimensions of this element will be used by default.
711 | */
712 | @Stable
713 | fun Modifier.flowShapes(
714 | flowType: FlowType = Outside,
715 | margin: Dp = 0.dp,
716 | flowShapes: List = emptyList()
717 | ): Modifier
718 |
719 | /**
720 | * Sets the shape used to flow text around this element. This variant of the [flowShape]
721 | * modifier accepts a lambda to define the shape used to flow text. That lambda receives
722 | * as parameters the size of this element and the size of the parent [BasicTextFlow] to
723 | * facilitate the computation of an appropriate [Path].
724 | *
725 | * @param flowType Defines how text flows around this element, see [FlowType].
726 | * @param margin The extra margin to add around this element for text flow.
727 | * @param flowShape A lambda that returns a [Path] defining the shape used to flow text
728 | * around this element. If the returned value is null, a rectangle of the dimensions of
729 | * this element will be used instead.
730 | */
731 | @Stable
732 | fun Modifier.flowShape(
733 | flowType: FlowType = Outside,
734 | margin: Dp = 0.dp,
735 | flowShape: FlowShapeProvider
736 | ): Modifier
737 |
738 | /**
739 | * Sets the shapes used to flow text around this element. This variant of the [flowShapes]
740 | * modifier accepts a lambda to define the shapes used to flow text. That lambda receives
741 | * as parameters the size of this element and the size of the parent [BasicTextFlow] to
742 | * facilitate the computation of an appropriate list of [Path].
743 | *
744 | * @param flowType Defines how text flows around this element, see [FlowType].
745 | * @param margin The extra margin to add around this element for text flow.
746 | * @param flowShapes A lambda that returns a list of [Path] defining the shapes used to flow
747 | * text around this element. If the list is empty, a rectangle of the dimensions of this
748 | * element will be used instead.
749 | */
750 | @Stable
751 | fun Modifier.flowShapes(
752 | flowType: FlowType = Outside,
753 | margin: Dp = 0.dp,
754 | flowShapes: FlowShapeListProvider
755 | ): Modifier
756 | }
757 |
758 | private object TextFlowScopeInstance : TextFlowScope {
759 | @Stable
760 | override fun Modifier.align(alignment: Alignment) = this.then(
761 | AlignmentAndSizeModifier(
762 | alignment = alignment,
763 | matchParentSize = false
764 | )
765 | )
766 |
767 | @Stable
768 | override fun Modifier.matchParentSize() = this.then(
769 | AlignmentAndSizeModifier(
770 | alignment = Alignment.Center,
771 | matchParentSize = true
772 | )
773 | )
774 |
775 | @Stable
776 | override fun Modifier.flowShape(flowType: FlowType, margin: Dp, flowShape: Path?) = this.then(
777 | FlowShapeModifier(flowType, margin) { _, _ ->
778 | if (flowShape == null) emptyList() else listOf(flowShape)
779 | }
780 | )
781 |
782 | @Stable
783 | override fun Modifier.flowShape(
784 | flowType: FlowType,
785 | margin: Dp,
786 | flowShape: FlowShapeProvider
787 | ) = this.then(
788 | FlowShapeModifier(flowType, margin) { size, containerSize ->
789 | val path = flowShape(size, containerSize)
790 | if (path == null) emptyList() else listOf(path)
791 | }
792 | )
793 |
794 | @Stable
795 | override fun Modifier.flowShapes(
796 | flowType: FlowType,
797 | margin: Dp,
798 | flowShapes: List
799 | ) = this.then(
800 | FlowShapeModifier(flowType, margin) { _, _ -> flowShapes }
801 | )
802 |
803 | @Stable
804 | override fun Modifier.flowShapes(
805 | flowType: FlowType,
806 | margin: Dp,
807 | flowShapes: FlowShapeListProvider
808 | ) = this.then(
809 | FlowShapeModifier(flowType, margin, flowShapes)
810 | )
811 | }
812 |
813 | private val Measurable.textFlowData: TextFlowParentData? get() = parentData as? TextFlowParentData
814 | private val Measurable.matchesParentSize: Boolean get() = textFlowData?.matchParentSize ?: false
815 |
816 | private class AlignmentAndSizeModifier(
817 | val alignment: Alignment,
818 | val matchParentSize: Boolean = false
819 | ) : ParentDataModifier, OnPlacedModifier {
820 | var localParentData: TextFlowParentData? = null
821 |
822 | override fun Density.modifyParentData(parentData: Any?): TextFlowParentData {
823 | localParentData = ((parentData as? TextFlowParentData) ?: TextFlowParentData()).also {
824 | it.alignment = alignment
825 | it.matchParentSize = matchParentSize
826 | }
827 | return localParentData!!
828 | }
829 |
830 | override fun onPlaced(coordinates: LayoutCoordinates) {
831 | localParentData?.position = coordinates.positionInParent()
832 | }
833 |
834 | override fun equals(other: Any?): Boolean {
835 | if (this === other) return true
836 | val otherModifier = other as? AlignmentAndSizeModifier ?: return false
837 |
838 | return alignment == otherModifier.alignment &&
839 | matchParentSize == otherModifier.matchParentSize
840 | }
841 |
842 | override fun hashCode(): Int {
843 | var result = alignment.hashCode()
844 | result = 31 * result + matchParentSize.hashCode()
845 | return result
846 | }
847 |
848 | override fun toString(): String =
849 | "AlignmentAndSizeModifier(alignment=$alignment, matchParentSize=$matchParentSize)"
850 | }
851 |
852 | private class FlowShapeModifier(
853 | val flowType: FlowType,
854 | val margin: Dp,
855 | val flowShape: FlowShapeListProvider
856 | ) : ParentDataModifier, OnPlacedModifier {
857 | var localParentData: TextFlowParentData? = null
858 |
859 | override fun Density.modifyParentData(parentData: Any?): TextFlowParentData {
860 | localParentData = ((parentData as? TextFlowParentData) ?: TextFlowParentData()).also {
861 | it.margin = margin
862 | it.flowType = flowType
863 | it.flowShapes = flowShape
864 | }
865 | return localParentData!!
866 | }
867 |
868 | override fun onPlaced(coordinates: LayoutCoordinates) {
869 | localParentData?.position = coordinates.positionInParent()
870 | }
871 |
872 | override fun equals(other: Any?): Boolean {
873 | if (this === other) return true
874 | if (javaClass != other?.javaClass) return false
875 |
876 | other as FlowShapeModifier
877 |
878 | if (margin != other.margin) return false
879 | if (flowType != other.flowType) return false
880 | if (flowShape != other.flowShape) return false
881 |
882 | return true
883 | }
884 |
885 | override fun hashCode(): Int {
886 | var result = margin.hashCode()
887 | result = 31 * result + flowType.hashCode()
888 | result = 31 * result + flowShape.hashCode()
889 | return result
890 | }
891 |
892 | override fun toString(): String {
893 | return "FlowShapeModifier(margin=$margin, flowType=$flowType)"
894 | }
895 | }
896 |
897 | private data class TextFlowParentData(
898 | var alignment: Alignment = Alignment.TopStart,
899 | var matchParentSize: Boolean = false,
900 | var margin: Dp = 0.dp,
901 | var flowType: FlowType = Outside,
902 | var flowShapes: FlowShapeListProvider = { _, _ -> emptyList() },
903 | var position: Offset = Offset.Unspecified
904 | )
905 |
906 | private val DefaultTextFlowParentData = TextFlowParentData()
907 |
908 | private object DebugColors {
909 | val SegmentColor = Color(0.941f, 0.384f, 0.573f, 1.0f)
910 |
911 | val LineBorder = Color(0.412f, 0.863f, 1.0f, 1.0f)
912 | val LineFill = Color(0.412f, 0.863f, 1.0f, 0.3f)
913 |
914 | val SecondaryLineFill = Color(1.0f, 0.945f, 0.463f, 0.5f)
915 |
916 | val StripeBackground = Color(0.98f, 0.98f, 0.98f)
917 | val StripeForeground = Color(0.94f, 0.94f, 0.94f)
918 | }
919 |
920 | private fun Density.debugStripeFill() = Brush.linearGradient(
921 | 0.00f to DebugColors.StripeBackground, 0.25f to DebugColors.StripeBackground,
922 | 0.25f to DebugColors.StripeForeground, 0.75f to DebugColors.StripeForeground,
923 | 0.75f to DebugColors.StripeBackground, 1.00f to DebugColors.StripeBackground,
924 | start = Offset.Zero,
925 | end = Offset(8.dp.toPx(), 8.dp.toPx()),
926 | tileMode = TileMode.Repeated
927 | )
928 |
--------------------------------------------------------------------------------