├── .github └── workflows │ ├── build.yml │ ├── gradle-wrapper-validation.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts ├── settings.gradle.kts └── src │ └── main │ └── kotlin │ ├── mordant-js-conventions.gradle.kts │ ├── mordant-js-sample-conventions.gradle.kts │ ├── mordant-jvm-sample-conventions.gradle.kts │ ├── mordant-kotlin-conventions.gradle.kts │ ├── mordant-mpp-conventions.gradle.kts │ ├── mordant-mpp-sample-conventions.gradle.kts │ ├── mordant-native-conventions.gradle.kts │ ├── mordant-native-core-conventions.gradle.kts │ ├── mordant-native-sample-conventions.gradle.kts │ └── mordant-publishing-conventions.gradle.kts ├── docs ├── css │ └── logo-styles.css ├── guide.md ├── img │ ├── animation.svg │ ├── animation_text.gif │ ├── gradient_black_36dp.svg │ ├── gradient_white_24dp.svg │ ├── progess_simple.gif │ ├── progress_cells.gif │ ├── progress_context.gif │ ├── progress_multi.gif │ ├── select_list.gif │ └── tour.png ├── input.md └── progress.md ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── mkdocs.yml ├── mordant-coroutines ├── README.md ├── api │ └── mordant-coroutines.api ├── build.gradle.kts ├── gradle.properties └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── animation │ │ └── coroutines │ │ │ └── CoroutineAnimator.kt │ │ └── input │ │ └── coroutines │ │ └── ReceiveEventsFlow.kt │ └── commonTest │ └── kotlin │ └── com │ └── github │ └── ajalt │ └── mordant │ └── animation │ └── coroutines │ └── CoroutinesAnimatorTest.kt ├── mordant-jvm-ffm ├── README.md ├── api │ └── mordant-jvm-ffm.api ├── build.gradle.kts ├── gradle.properties └── src │ └── jvmMain │ ├── kotlin │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── terminal │ │ └── terminalinterface │ │ └── ffm │ │ ├── FfmLayouts.kt │ │ ├── TerminalInterface.ffm.linux.kt │ │ ├── TerminalInterface.ffm.macos.kt │ │ ├── TerminalInterface.ffm.windows.kt │ │ └── TerminalInterfaceProvider.ffm.kt │ └── resources │ └── META-INF │ ├── native-image │ └── com.github.ajalt.mordant │ │ └── mordant-jvm-ffm │ │ ├── reflection-config.json │ │ └── resource-config.json │ └── services │ └── com.github.ajalt.mordant.terminal.TerminalInterfaceProvider ├── mordant-jvm-graal-ffi ├── README.md ├── api │ └── mordant-jvm-graal-ffi.api ├── build.gradle.kts ├── gradle.properties └── src │ └── jvmMain │ ├── kotlin │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── terminal │ │ └── terminalinterface │ │ └── nativeimage │ │ ├── TerminalInterface.nativeimage.linux.kt │ │ ├── TerminalInterface.nativeimage.macos.kt │ │ ├── TerminalInterface.nativeimage.windows.kt │ │ └── TerminalInterfaceProvider.nativeimage.kt │ └── resources │ └── META-INF │ ├── native-image │ └── com.github.ajalt.mordant │ │ └── mordant-jvm-graal-ffi │ │ ├── reflection-config.json │ │ └── resource-config.json │ ├── proguard │ └── mordant-jvm-graal-ffi.pro │ └── services │ └── com.github.ajalt.mordant.terminal.TerminalInterfaceProvider ├── mordant-jvm-jna ├── README.md ├── api │ └── mordant-jvm-jna.api ├── build.gradle.kts ├── gradle.properties └── src │ └── jvmMain │ ├── kotlin │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── terminal │ │ └── terminalinterface │ │ └── jna │ │ ├── TerminalInterface.jna.linux.kt │ │ ├── TerminalInterface.jna.macos.kt │ │ ├── TerminalInterface.jna.windows.kt │ │ └── TerminalInterfaceProvider.jna.kt │ └── resources │ └── META-INF │ ├── native-image │ └── com.github.ajalt.mordant │ │ └── mordant-jvm-jna │ │ ├── reflection-config.json │ │ └── resource-config.json │ ├── proguard │ └── mordant-jvm-jna.pro │ └── services │ └── com.github.ajalt.mordant.terminal.TerminalInterfaceProvider ├── mordant-markdown ├── README.md ├── api │ └── mordant-markdown.api ├── build.gradle.kts ├── gradle.properties └── src │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── markdown │ │ ├── BlockQuote.kt │ │ ├── Markdown.kt │ │ └── MarkdownRenderer.kt │ └── commonTest │ └── kotlin │ └── com │ └── github │ └── ajalt │ └── mordant │ └── markdown │ └── MarkdownTest.kt ├── mordant-omnibus ├── README.md ├── api │ └── mordant-omnibus.api ├── build.gradle.kts ├── gradle.properties └── src │ └── commonMain │ └── kotlin │ └── com │ └── github │ └── ajalt │ └── mordant │ └── internal │ └── OmibusInternal.kt ├── mordant ├── README.md ├── api │ └── mordant.api ├── build.gradle.kts ├── gradle.properties └── src │ ├── appleNonDesktopMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── internal │ │ └── MppInternal.appleNonDesktop.kt │ ├── commonMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── animation │ │ ├── Animation.kt │ │ ├── RefreshableAnimation.kt │ │ ├── StoppableAnimation.kt │ │ └── progress │ │ │ ├── MultiProgressBarAnimation.kt │ │ │ └── ProgressBarAnimation.kt │ │ ├── input │ │ ├── InputEvent.kt │ │ ├── InputReceiver.kt │ │ ├── InteractiveSelectList.kt │ │ ├── MouseTracking.kt │ │ ├── RawMode.kt │ │ ├── ReceiveEvents.kt │ │ └── SelectListAnimation.kt │ │ ├── internal │ │ ├── AnsiCodes.kt │ │ ├── AnsiRender.kt │ │ ├── BlankWidgetWrapper.kt │ │ ├── Constants.kt │ │ ├── Formatting.kt │ │ ├── HyperlinkIds.kt │ │ ├── MppInternal.kt │ │ ├── Parsing.kt │ │ ├── ThemeValue.kt │ │ ├── Utf8.kt │ │ ├── cellwidth.kt │ │ └── gen │ │ │ ├── ZwjSequences1.kt │ │ │ ├── ZwjSequences2.kt │ │ │ ├── ZwjSequences3.kt │ │ │ ├── ZwjSequences4.kt │ │ │ ├── cellwidthtable.kt │ │ │ └── emojiseqtable.kt │ │ ├── platform │ │ └── MultiplatformSystem.kt │ │ ├── rendering │ │ ├── Align.kt │ │ ├── BorderType.kt │ │ ├── Lines.kt │ │ ├── OverflowWrap.kt │ │ ├── Size.kt │ │ ├── Span.kt │ │ ├── TextColors.kt │ │ ├── TextStyle.kt │ │ ├── Theme.kt │ │ ├── Whitespace.kt │ │ ├── Widget.kt │ │ └── WidthRange.kt │ │ ├── table │ │ ├── Borders.kt │ │ ├── Table.kt │ │ ├── TableCsv.kt │ │ ├── TableDsl.kt │ │ ├── TableDslInstances.kt │ │ ├── TableLayout.kt │ │ └── VerticalLayout.kt │ │ ├── terminal │ │ ├── HtmlRenderer.kt │ │ ├── Prompt.kt │ │ ├── StandardTerminalInterface.kt │ │ ├── Terminal.kt │ │ ├── TerminalCursor.kt │ │ ├── TerminalDetection.kt │ │ ├── TerminalExtensions.kt │ │ ├── TerminalInfo.kt │ │ ├── TerminalInterceptor.kt │ │ ├── TerminalInterface.kt │ │ ├── TerminalInterfaceProvider.kt │ │ ├── TerminalRecorder.kt │ │ └── terminalinterface │ │ │ ├── PosixEventParser.kt │ │ │ ├── TerminalInterface.posix.kt │ │ │ ├── TerminalInterface.windows.kt │ │ │ └── WindowsVirtualKeyCodeToKeyEvent.kt │ │ └── widgets │ │ ├── Caption.kt │ │ ├── DefinitionList.kt │ │ ├── EmptyWidget.kt │ │ ├── HorizontalRule.kt │ │ ├── OrderedList.kt │ │ ├── Padding.kt │ │ ├── Panel.kt │ │ ├── ProgressBar.kt │ │ ├── SelectList.kt │ │ ├── Spinner.kt │ │ ├── Text.kt │ │ ├── UnorderedList.kt │ │ ├── Viewport.kt │ │ └── progress │ │ ├── CachedProgressBarDefinition.kt │ │ ├── ProgressBarWidgetMaker.kt │ │ ├── ProgressLayoutCells.kt │ │ ├── ProgressLayoutScope.kt │ │ └── ProgressState.kt │ ├── commonTest │ ├── kotlin │ │ └── com │ │ │ └── github │ │ │ └── ajalt │ │ │ └── mordant │ │ │ ├── animation │ │ │ ├── AnimationTest.kt │ │ │ └── progress │ │ │ │ └── BaseProgressAnimationTest.kt │ │ │ ├── input │ │ │ ├── InteractiveSelectListTest.kt │ │ │ └── SelectListAnimationTest.kt │ │ │ ├── platform │ │ │ └── MultiplatformSystemTest.kt │ │ │ ├── rendering │ │ │ ├── TextAlignmentTest.kt │ │ │ ├── TextOverflowWrapTest.kt │ │ │ ├── TextStyleOscTest.kt │ │ │ ├── TextWhitespaceTest.kt │ │ │ ├── ThemeTest.kt │ │ │ └── internal │ │ │ │ └── CellWidthTest.kt │ │ │ ├── table │ │ │ ├── LinearLayoutTest.kt │ │ │ ├── TableAlignmentTest.kt │ │ │ ├── TableBorderStyleTest.kt │ │ │ ├── TableBorderTest.kt │ │ │ ├── TableColumnWidthTest.kt │ │ │ ├── TableCsvTest.kt │ │ │ └── TableTest.kt │ │ │ ├── terminal │ │ │ ├── HtmlRendererTest.kt │ │ │ ├── PromptTest.kt │ │ │ ├── TerminalCursorTest.kt │ │ │ └── TerminalTest.kt │ │ │ ├── test │ │ │ ├── RenderingTest.kt │ │ │ └── TestUtils.kt │ │ │ └── widgets │ │ │ ├── DefinitionListTest.kt │ │ │ ├── HorizontalRuleTest.kt │ │ │ ├── MultiProgressLayoutTest.kt │ │ │ ├── OrderedListTest.kt │ │ │ ├── PaddingTest.kt │ │ │ ├── PanelTest.kt │ │ │ ├── ProgressBarTest.kt │ │ │ ├── ProgressLayoutTest.kt │ │ │ ├── SelectListTest.kt │ │ │ ├── SpinnerTest.kt │ │ │ ├── TextTest.kt │ │ │ ├── UnorderedListTest.kt │ │ │ └── ViewportTest.kt │ └── resources │ │ └── multiplatform_system_test.txt │ ├── iosMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── internal │ │ └── MppInternal.ios.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.native.ios.kt │ ├── jsCommonMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── internal │ │ └── MppInternal.jsCommon.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.jsCommon.kt │ ├── jsMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── internal │ │ ├── JsCompat.kt │ │ └── MppInternal.js.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.js.kt │ ├── jvmMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── animation │ │ └── progress │ │ │ └── ThreadAnimator.kt │ │ ├── internal │ │ └── MppInternal.jvm.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.jvm.posix.kt │ ├── jvmTest │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── animation │ │ └── progress │ │ │ └── ThreadAnimatorTest.kt │ │ └── terminal │ │ └── StderrTerminalTest.kt │ ├── linuxMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── internal │ │ └── MppInternal.linux.kt │ ├── macosMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── internal │ │ └── MppInternal.macos.kt │ ├── mingwMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── internal │ │ └── MppInternal.mingw.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.native.windows.kt │ ├── nativeMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── internal │ │ └── MppInternal.native.kt │ ├── posixMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── internal │ │ └── tty.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.native.posix.kt │ ├── posixSharedMain │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ ├── internal │ │ └── MppInternal.native.posixshared.kt │ │ └── terminal │ │ └── terminalinterface │ │ └── TerminalInterface.native.shared.kt │ └── wasmJsMain │ └── kotlin │ └── com │ └── github │ └── ajalt │ └── mordant │ ├── internal │ └── MppInternal.wasmJs.kt │ └── terminal │ └── terminalinterface │ └── TerminalInterface.wasm.kt ├── prepare_docs.sh ├── runsample ├── runsample.bat ├── samples ├── detection │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt ├── drawing │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt ├── hexviewer │ ├── README.md │ ├── build.gradle.kts │ ├── example.png │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt ├── markdown │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt ├── progress │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── jvmMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt ├── select │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt ├── table │ ├── README.md │ ├── build.gradle.kts │ └── src │ │ └── commonMain │ │ └── kotlin │ │ └── com │ │ └── github │ │ └── ajalt │ │ └── mordant │ │ └── samples │ │ └── main.kt └── tour │ ├── build.gradle.kts │ └── src │ └── commonMain │ └── kotlin │ └── com │ └── github │ └── ajalt │ └── mordant │ └── samples │ └── main.kt ├── scripts ├── generate_cellwidth_table.py └── generate_emoji_sequence_table.py ├── settings.gradle.kts └── test ├── graalvm ├── build.gradle.kts └── src │ └── test │ └── kotlin │ └── GraalSmokeTest.kt └── proguard ├── build.gradle.kts └── src └── main ├── kotlin └── R8SmokeTest.kt └── rules.pro /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - 'docs/**' 7 | - 'samples/**' 8 | - '*.md' 9 | push: 10 | branches: 11 | - 'master' 12 | paths-ignore: 13 | - 'docs/**' 14 | - 'samples/**' 15 | - '*.md' 16 | jobs: 17 | test: 18 | strategy: 19 | matrix: 20 | os: [macos-latest, windows-latest, ubuntu-latest] 21 | include: 22 | - os: ubuntu-latest 23 | EXTRA_GRADLE_ARGS: apiCheck :test:proguard:r8jar 24 | - os: macos-latest 25 | EXTRA_GRADLE_ARGS: >- 26 | :mordant:compileNativeMainKotlinMetadata 27 | :mordant:compilePosixMainKotlinMetadata 28 | :mordant:compileAppleMainKotlinMetadata 29 | :mordant:compileMacosMainKotlinMetadata 30 | :mordant:compileWatchosMainKotlinMetadata 31 | :mordant:compileTvosMainKotlinMetadata 32 | runs-on: ${{matrix.os}} 33 | steps: 34 | - uses: actions/checkout@v4 35 | - uses: graalvm/setup-graalvm@v1 36 | with: 37 | java-version: 22 38 | distribution: 'graalvm-community' 39 | set-java-home: false 40 | - uses: actions/setup-java@v4 41 | with: 42 | distribution: 'zulu' 43 | java-version: 22 44 | - uses: gradle/actions/setup-gradle@v3 45 | - name: Run tests 46 | run: >- 47 | ./gradlew 48 | ${{matrix.EXTRA_GRADLE_ARGS}} 49 | :mordant:check 50 | :mordant-coroutines:check 51 | :mordant-markdown:check 52 | :test:graalvm:nativeTest 53 | --stacktrace 54 | - name: Run R8 Jar 55 | if: ${{ matrix.os == 'ubuntu-latest' }} 56 | run: java -jar test/proguard/build/libs/main-r8.jar 57 | - name: Upload the build report 58 | if: failure() 59 | uses: actions/upload-artifact@master 60 | with: 61 | name: build-report-${{ matrix.os }} 62 | path: '**/build/reports' 63 | publish-snapshot: 64 | needs: test 65 | runs-on: macos-latest 66 | if: ${{ github.ref == 'refs/heads/master' && github.repository == 'ajalt/mordant' }} 67 | steps: 68 | - uses: actions/checkout@v4 69 | - uses: actions/setup-java@v4 70 | with: 71 | distribution: 'zulu' 72 | java-version: 22 73 | - uses: gradle/actions/setup-gradle@v3 74 | - name: Deploy to sonatype 75 | # disable configuration cache due to https://github.com/gradle/gradle/issues/22779 76 | run: ./gradlew publishToMavenCentral -PsnapshotVersion=true --no-configuration-cache 77 | env: 78 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_mavenCentralPassword }} 79 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_signingInMemoryKey }} 80 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_mavenCentralUsername }} 81 | env: 82 | GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx12g -Dfile.encoding=UTF-8" -Dorg.gradle.configureondemand=true -Dorg.gradle.parallel=false -Dkotlin.incremental=false -Dorg.gradle.project.kotlin.incremental.multiplatform=false -Dorg.gradle.project.kotlin.native.disableCompilerDaemon=true 83 | -------------------------------------------------------------------------------- /.github/workflows/gradle-wrapper-validation.yml: -------------------------------------------------------------------------------- 1 | name: "Validate Gradle Wrapper" 2 | on: 3 | pull_request: 4 | paths: 5 | - 'gradlew' 6 | - 'gradlew.bat' 7 | - 'gradle/wrapper/' 8 | 9 | 10 | jobs: 11 | validation: 12 | name: "Validation" 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: gradle/actions/wrapper-validation@v3 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | jobs: 9 | release: 10 | runs-on: macos-latest 11 | if: ${{ github.repository == 'ajalt/mordant' }} 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-java@v4 15 | with: 16 | distribution: 'zulu' 17 | java-version: 22 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.12' 21 | - uses: gradle/actions/setup-gradle@v3 22 | - run: ./gradlew publishToMavenCentral --no-configuration-cache 23 | env: 24 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.ORG_GRADLE_PROJECT_mavenCentralPassword }} 25 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ORG_GRADLE_PROJECT_signingInMemoryKey }} 26 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.ORG_GRADLE_PROJECT_mavenCentralUsername }} 27 | # Disabled due to https://github.com/ffurrer2/extract-release-notes/issues/339 28 | # - name: Extract release notes 29 | # id: extract-release-notes 30 | # uses: ffurrer2/extract-release-notes@v2 31 | # - name: Create release 32 | # uses: ncipollo/release-action@v1 33 | # with: 34 | # body: ${{ steps.extract-release-notes.outputs.release_notes }} 35 | # - name: Dokka 36 | # uses: gradle/actions/setup-gradle@v3 37 | # with: 38 | # arguments: dokkaHtmlMultiModule 39 | # - run : ./prepare_docs.sh 40 | # - name: Build mkdocs 41 | # run: | 42 | # pip install mkdocs-material 43 | # mkdocs build 44 | # - name: Deploy docs to website 45 | # uses: JamesIves/github-pages-deploy-action@v4 46 | # with: 47 | # branch: gh-pages 48 | # folder: site 49 | env: 50 | # macos-latest is now macos-14 and has less than half as much memory available as other runners 51 | GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -Dfile.encoding=UTF-8" -Dorg.gradle.configureondemand=true -Dorg.gradle.parallel=false -Dkotlin.incremental=false -Dorg.gradle.project.kotlin.incremental.multiplatform=false -Dorg.gradle.project.kotlin.native.disableCompilerDaemon=true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/ 5 | .DS_Store 6 | build/ 7 | captures/ 8 | .externalNativeBuild 9 | out/ 10 | docs/api/ 11 | docs/changelog.md 12 | docs/index.md 13 | site/ 14 | kotlin-js-store/ 15 | .kotlin/ 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | Mordant is a multiplatform library for rendering styled text in the terminal. You can use it to 6 | add color and style to text, create tables, draw animations, and more. 7 | 8 | Mordant has: 9 | 10 | * Easy colorful ANSI output with automatic detection of terminal capabilities 11 | * Markdown rendering directly to the terminal 12 | * Widgets for laying out terminal output, including lists, tables, panels, and more 13 | * Support for animating any widget, like progress bars and dashboards 14 | 15 | ## Documentation 16 | 17 | The full documentation can be found on [the website](https://ajalt.github.io/mordant/). 18 | 19 | ## Installation 20 | 21 | Mordant is distributed through Maven Central. 22 | 23 | ```groovy 24 | dependencies { 25 | implementation("com.github.ajalt.mordant:mordant:3.0.2") 26 | 27 | // optional extensions for running animations with coroutines 28 | implementation("com.github.ajalt.mordant:mordant-coroutines:3.0.2") 29 | 30 | // optional widget for rendering Markdown 31 | implementation("com.github.ajalt.mordant:mordant-markdown:3.0.2") 32 | } 33 | ``` 34 | 35 | On JVM, there are more granular dependencies available. 36 | [See the docs for details](https://ajalt.github.io/mordant/guide/). 37 | 38 | ###### If you're using Maven instead of Gradle, use `mordant-jvm` 39 | 40 | #### Snapshots 41 | 42 |
43 | Snapshot builds are also available 44 | 45 | 46 | 47 |

48 | You'll need to add the Sonatype snapshots repository: 49 | 50 | ```kotlin 51 | repositories { 52 | maven { 53 | url = uri("https://oss.sonatype.org/content/repositories/snapshots/") 54 | } 55 | } 56 | ``` 57 |

58 |
59 | 60 | ## License 61 | 62 | Copyright 2018 AJ Alt 63 | 64 | Licensed under the Apache License, Version 2.0 (the "License"); 65 | you may not use this file except in compliance with the License. 66 | You may obtain a copy of the License at 67 | 68 | http://www.apache.org/licenses/LICENSE-2.0 69 | 70 | Unless required by applicable law or agreed to in writing, software 71 | distributed under the License is distributed on an "AS IS" BASIS, 72 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 73 | See the License for the specific language governing permissions and 74 | limitations under the License. 75 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.dokka.gradle.DokkaMultiModuleTask 2 | import org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask 3 | 4 | plugins { 5 | alias(libs.plugins.kotlinBinaryCompatibilityValidator) 6 | id("org.jetbrains.dokka") 7 | } 8 | 9 | apiValidation { 10 | // https://github.com/Kotlin/binary-compatibility-validator/issues/3 11 | project("samples").subprojects.mapTo(ignoredProjects) { it.name } 12 | project("test").subprojects.mapTo(ignoredProjects) { it.name } 13 | } 14 | 15 | tasks.withType().configureEach { 16 | outputDirectory.set(rootProject.rootDir.resolve("docs/api")) 17 | pluginsMapConfiguration.set( 18 | mapOf( 19 | "org.jetbrains.dokka.base.DokkaBase" to """{ 20 | "footerMessage": "Copyright © 2017 AJ Alt" 21 | }""" 22 | ) 23 | ) 24 | } 25 | 26 | // https://youtrack.jetbrains.com/issue/KT-63014 27 | tasks.withType().configureEach { 28 | args.add("--ignore-engines") 29 | } 30 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | repositories { 6 | mavenCentral() 7 | gradlePluginPortal() 8 | } 9 | 10 | dependencies { 11 | implementation(libs.kotlin.gradle.plugin) 12 | implementation(libs.publish) 13 | implementation(libs.dokka) 14 | } 15 | -------------------------------------------------------------------------------- /buildSrc/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "buildSrc" 2 | 3 | dependencyResolutionManagement { 4 | versionCatalogs { 5 | create("libs") { 6 | from(files("../gradle/libs.versions.toml")) 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-js-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl 2 | import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension 3 | 4 | plugins { 5 | kotlin("multiplatform") 6 | } 7 | 8 | kotlin { 9 | js { 10 | // We have different code paths on browsers and node, so we run tests on both 11 | nodejs() 12 | browser() 13 | } 14 | @OptIn(ExperimentalWasmDsl::class) 15 | wasmJs { 16 | nodejs() 17 | browser() 18 | } 19 | 20 | sourceSets { 21 | val jsCommonMain by creating { dependsOn(commonMain.get()) } 22 | jsMain.get().dependsOn(jsCommonMain) 23 | wasmJsMain.get().dependsOn(jsCommonMain) 24 | } 25 | } 26 | 27 | // Need to compile using a canary version of Node due to 28 | // https://youtrack.jetbrains.com/issue/KT-63014 29 | rootProject.the().apply { 30 | version = "21.0.0-v8-canary2023091837d0630120" 31 | downloadBaseUrl = "https://nodejs.org/download/v8-canary" 32 | } 33 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-js-sample-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl 2 | 3 | plugins { 4 | kotlin("multiplatform") 5 | } 6 | 7 | kotlin { 8 | js { 9 | nodejs() 10 | binaries.executable() 11 | } 12 | @OptIn(ExperimentalWasmDsl::class) 13 | wasmJs { 14 | nodejs() 15 | binaries.executable() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-jvm-sample-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-kotlin-conventions") 3 | application 4 | } 5 | 6 | kotlin { 7 | // For some reason MainKt isn't present in the jar unless we add withJava 8 | jvm { withJava() } 9 | 10 | sourceSets { 11 | jvmMain.dependencies { 12 | implementation(project(":mordant-omnibus")) 13 | } 14 | } 15 | } 16 | 17 | application { 18 | mainClass.set("com.github.ajalt.mordant.samples.MainKt") 19 | applicationDefaultJvmArgs = listOf( 20 | "-Dfile.encoding=utf-8", 21 | "--enable-native-access=ALL-UNNAMED" 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-kotlin-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile 3 | 4 | plugins { 5 | kotlin("multiplatform") 6 | } 7 | 8 | tasks.withType().configureEach { 9 | compilerOptions { 10 | freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") 11 | jvmTarget.set(JvmTarget.JVM_1_8) 12 | } 13 | } 14 | 15 | tasks.withType().configureEach { 16 | options.release.set(8) 17 | } 18 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-mpp-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-kotlin-conventions") 3 | id("mordant-native-conventions") 4 | id("mordant-js-conventions") 5 | } 6 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-mpp-sample-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-jvm-sample-conventions") 3 | id("mordant-native-sample-conventions") 4 | id("mordant-js-sample-conventions") 5 | } 6 | 7 | kotlin { 8 | sourceSets { 9 | commonMain.dependencies { 10 | implementation(project(":mordant")) 11 | } 12 | jvmMain.dependencies { 13 | implementation(project(":mordant-omnibus")) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-native-core-conventions") 3 | } 4 | 5 | kotlin { 6 | // Add targets not supported by the markdown library 7 | tvosX64() 8 | tvosArm64() 9 | tvosSimulatorArm64() 10 | watchosArm32() 11 | watchosArm64() 12 | watchosX64() 13 | watchosSimulatorArm64() 14 | } 15 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-native-core-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | } 4 | 5 | kotlin { 6 | applyDefaultHierarchyTemplate() 7 | 8 | linuxX64() 9 | linuxArm64() 10 | macosX64() 11 | macosArm64() 12 | mingwX64() 13 | 14 | iosX64() 15 | iosArm64() 16 | iosSimulatorArm64() 17 | // Not all targets are supported by the markdown library 18 | // tvosX64() 19 | // tvosArm64() 20 | // tvosSimulatorArm64() 21 | // watchosArm32() 22 | // watchosArm64() 23 | // watchosDeviceArm64() 24 | // watchosX64() 25 | // watchosSimulatorArm64() 26 | } 27 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-native-sample-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget 2 | 3 | plugins { 4 | id("mordant-native-conventions") 5 | } 6 | 7 | kotlin.targets.filterIsInstance().forEach { target -> 8 | target.binaries.executable { 9 | entryPoint = "com.github.ajalt.mordant.samples.main" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/mordant-publishing-conventions.gradle.kts: -------------------------------------------------------------------------------- 1 | @file:Suppress("PropertyName") 2 | 3 | import com.vanniktech.maven.publish.JavadocJar 4 | import com.vanniktech.maven.publish.KotlinMultiplatform 5 | import com.vanniktech.maven.publish.SonatypeHost 6 | import org.jetbrains.dokka.gradle.DokkaTaskPartial 7 | 8 | plugins { 9 | id("com.vanniktech.maven.publish.base") 10 | id("org.jetbrains.dokka") 11 | } 12 | 13 | fun getPublishVersion(): String { 14 | val version = project.property("VERSION_NAME").toString() 15 | // Call gradle with -PsnapshotVersion to set the version as a snapshot. 16 | if (!project.hasProperty("snapshotVersion")) return version 17 | val buildNumber = System.getenv("GITHUB_RUN_NUMBER") ?: "0" 18 | return "$version.$buildNumber-SNAPSHOT" 19 | } 20 | 21 | // Since we want to set the version name dynamically, we have to use the base plugin 22 | @Suppress("UnstableApiUsage") 23 | mavenPublishing { 24 | project.setProperty("VERSION_NAME", getPublishVersion()) 25 | pomFromGradleProperties() 26 | configure(KotlinMultiplatform(JavadocJar.Empty())) 27 | publishToMavenCentral(SonatypeHost.DEFAULT) 28 | signAllPublications() 29 | } 30 | 31 | tasks.withType().configureEach { 32 | dokkaSourceSets.configureEach { 33 | skipDeprecated.set(true) 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /docs/css/logo-styles.css: -------------------------------------------------------------------------------- 1 | /* Custom CSS for Dokka docs */ 2 | 3 | #logo { 4 | background-image: url('../images/gradient_black_36dp.svg'); 5 | } 6 | 7 | /* Add the project name to the logo */ 8 | #logo::before { 9 | content: "Mordant"; 10 | margin-left: 80px; 11 | color: black; 12 | font-size: 20px; 13 | font-weight: 600; 14 | } 15 | 16 | /* Remove the "What's on this Page" tab that covers up the scroll bar */ 17 | .page-summary { 18 | display: none; 19 | } 20 | -------------------------------------------------------------------------------- /docs/img/animation_text.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/animation_text.gif -------------------------------------------------------------------------------- /docs/img/gradient_black_36dp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/img/gradient_white_24dp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/img/progess_simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/progess_simple.gif -------------------------------------------------------------------------------- /docs/img/progress_cells.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/progress_cells.gif -------------------------------------------------------------------------------- /docs/img/progress_context.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/progress_context.gif -------------------------------------------------------------------------------- /docs/img/progress_multi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/progress_multi.gif -------------------------------------------------------------------------------- /docs/img/select_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/select_list.gif -------------------------------------------------------------------------------- /docs/img/tour.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/docs/img/tour.png -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | VERSION_NAME=3.0.2 2 | 3 | kotlin.mpp.stability.nowarn=true 4 | 5 | # gradle-maven-publish configuration 6 | SONATYPE_HOST=DEFAULT 7 | RELEASE_SIGNING_ENABLED=true 8 | GROUP=com.github.ajalt.mordant 9 | POM_DESCRIPTION=Colorful multiplatform styling Kotlin for command-line applications 10 | POM_INCEPTION_YEAR=2018 11 | POM_URL=https://github.com/ajalt/mordant/ 12 | POM_LICENSE_NAME=Apache-2.0 13 | POM_LICENSE_URL=https://www.apache.org/licenses/LICENSE-2.0 14 | POM_LICENSE_DIST=repo 15 | POM_SCM_URL=https://github.com/ajalt/ 16 | POM_SCM_CONNECTION=scm:git:git://github.com/ajalt/mordant.git 17 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/ajalt/mordant.git 18 | POM_DEVELOPER_ID=ajalt 19 | POM_DEVELOPER_NAME=AJ Alt 20 | POM_DEVELOPER_URL=https://github.com/ajalt/ 21 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.0.10" 3 | coroutines = "1.8.1" 4 | 5 | [libraries] 6 | colormath = "com.github.ajalt.colormath:colormath:3.6.0" 7 | markdown = "org.jetbrains:markdown:0.7.3" 8 | jna-core = "net.java.dev.jna:jna:5.14.0" 9 | 10 | # compileOnly 11 | graalvm-svm = "org.graalvm.nativeimage:svm:23.1.0" 12 | 13 | # used in extensions 14 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 15 | 16 | # used in tests 17 | kotest = "io.kotest:kotest-assertions-core:5.9.1" 18 | systemrules = "com.github.stefanbirkner:system-rules:1.19.0" 19 | r8 = "com.android.tools:r8:8.3.37" 20 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 21 | 22 | # build logic 23 | kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } 24 | dokka = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version = "1.9.20" } 25 | publish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.28.0" } 26 | 27 | [plugins] 28 | graalvm-nativeimage = "org.graalvm.buildtools.native:0.9.28" 29 | kotlinBinaryCompatibilityValidator = "org.jetbrains.kotlinx.binary-compatibility-validator:0.14.0" 30 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /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 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 1>&2 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 48 | echo. 1>&2 49 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 50 | echo location of your Java installation. 1>&2 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 1>&2 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 62 | echo. 1>&2 63 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 64 | echo location of your Java installation. 1>&2 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Mordant 2 | repo_name: Mordant 3 | repo_url: https://github.com/ajalt/mordant 4 | site_description: "Mordant: Multiplatform text styling for Kotlin command-line applications" 5 | site_author: AJ Alt 6 | remote_branch: gh-pages 7 | 8 | copyright: 'Copyright © 2018 AJ Alt' 9 | 10 | theme: 11 | name: 'material' 12 | logo: img/gradient_white_24dp.svg 13 | favicon: img/favicon.ico 14 | icon: 15 | repo: fontawesome/brands/github 16 | palette: 17 | - media: "(prefers-color-scheme: light)" 18 | scheme: default 19 | primary: indigo 20 | accent: indigo 21 | toggle: 22 | icon: material/brightness-7 23 | name: Switch to dark mode 24 | 25 | - media: "(prefers-color-scheme: dark)" 26 | scheme: slate 27 | primary: indigo 28 | accent: indigo 29 | toggle: 30 | icon: material/brightness-4 31 | name: Switch to light mode 32 | 33 | markdown_extensions: 34 | - smarty 35 | - codehilite: 36 | guess_lang: false 37 | - footnotes 38 | - meta 39 | - toc: 40 | permalink: true 41 | - admonition 42 | - pymdownx.betterem: 43 | smart_enable: all 44 | - pymdownx.details 45 | - pymdownx.caret 46 | - pymdownx.details 47 | - pymdownx.inlinehilite 48 | - pymdownx.magiclink 49 | - pymdownx.smartsymbols 50 | - pymdownx.superfences 51 | - pymdownx.tabbed: 52 | alternate_style: true 53 | - tables 54 | - admonition 55 | 56 | nav: 57 | - 'Getting Started': guide.md 58 | - 'Progress Bars': progress.md 59 | - 'Keyboard and Mouse Input': input.md 60 | - 'API Reference': api/index.html 61 | - 'Releases': changelog.md 62 | -------------------------------------------------------------------------------- /mordant-coroutines/README.md: -------------------------------------------------------------------------------- 1 | # Mordant Coroutines 2 | 3 | This module adds extensions to running animations with coroutines. 4 | 5 | ```kotlin 6 | implementation("com.github.ajalt.mordant:mordant-coroutines:$mordantVersion") 7 | ``` 8 | -------------------------------------------------------------------------------- /mordant-coroutines/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-conventions") 3 | id("mordant-publishing-conventions") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | sourceSets { 9 | commonMain.dependencies { 10 | api(project(":mordant")) 11 | api(libs.coroutines.core) 12 | } 13 | commonTest.dependencies { 14 | implementation(kotlin("test")) 15 | implementation(libs.kotest) 16 | implementation(libs.coroutines.test) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mordant-coroutines/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant-coroutines 2 | POM_NAME=Mordant Coroutines Extensions 3 | -------------------------------------------------------------------------------- /mordant-coroutines/src/commonMain/kotlin/com/github/ajalt/mordant/input/coroutines/ReceiveEventsFlow.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.input.coroutines 2 | 3 | import com.github.ajalt.mordant.input.* 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlinx.coroutines.flow.filterIsInstance 7 | import kotlinx.coroutines.flow.flow 8 | 9 | /** 10 | * Enter raw mode, emit input events until the flow in cancelled, then exit raw mode. 11 | * 12 | * @param mouseTracking The type of mouse tracking to enable. 13 | */ 14 | fun Terminal.receiveEventsFlow( 15 | mouseTracking: MouseTracking = MouseTracking.Normal, 16 | ): Flow = flow { 17 | enterRawMode(mouseTracking).use { 18 | while (true) emit(it.readEvent()) 19 | } 20 | } 21 | 22 | /** 23 | * Enter raw mode, emit [KeyboardEvent]s until the flow in cancelled, then exit raw mode. 24 | */ 25 | fun Terminal.receiveKeyEventsFlow( 26 | ): Flow = receiveEventsFlow(MouseTracking.Off).filterIsInstance() 27 | 28 | /** 29 | * Enter raw mode, emit [MouseEvent]s until the flow in cancelled, then exit raw mode. 30 | * 31 | * @param mouseTracking The type of mouse tracking to enable. 32 | */ 33 | fun Terminal.receiveMouseEventsFlow( 34 | mouseTracking: MouseTracking = MouseTracking.Normal, 35 | ): Flow { 36 | require(mouseTracking != MouseTracking.Off) { 37 | "Mouse tracking must be enabled to receive mouse events" 38 | } 39 | return receiveEventsFlow(mouseTracking).filterIsInstance() 40 | } 41 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/README.md: -------------------------------------------------------------------------------- 1 | # Mordant JVM FFM 2 | 3 | This is a JVM-only module that adds a `TerminalInterface` implementation that uses the Java Foreign 4 | Functions and Memory APIs. It requires JDK 22 or newer, and, like all usages of FFM, requires that 5 | you add `--enable-native-access=ALL-UNNAMED` to your `java` command line arguments or 6 | `Enable-Native-Access: ALL-UNNAMED` to the manifest of your executable JAR. 7 | 8 | ```kotlin 9 | implementation("com.github.ajalt.mordant:mordant-jvm-ffm:$mordantVersion") 10 | ``` 11 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/api/mordant-jvm-ffm.api: -------------------------------------------------------------------------------- 1 | public final class com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterfaceProviderFfm : com/github/ajalt/mordant/terminal/TerminalInterfaceProvider { 2 | public fun ()V 3 | public fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface; 4 | } 5 | 6 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-kotlin-conventions") 3 | id("mordant-publishing-conventions") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | sourceSets { 9 | commonMain.dependencies { 10 | implementation(project(":mordant")) 11 | } 12 | jvmMain.dependencies { 13 | compileOnly(libs.graalvm.svm) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant-jvm-ffm 2 | POM_NAME=Mordant Foreign Function & Memory Interface 3 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/ffm/TerminalInterfaceProvider.ffm.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface.ffm 2 | 3 | import com.github.ajalt.mordant.terminal.TerminalInterface 4 | import com.github.ajalt.mordant.terminal.TerminalInterfaceProvider 5 | import com.oracle.svm.core.annotate.Substitute 6 | import com.oracle.svm.core.annotate.TargetClass 7 | 8 | class TerminalInterfaceProviderFfm : TerminalInterfaceProvider { 9 | override fun load(): TerminalInterface? { 10 | try { 11 | if (!TerminalInterfaceProviderFfm::class.java.module.isNativeAccessEnabled()) { 12 | return null 13 | } 14 | } catch (e: NoSuchMethodError) { 15 | // isNativeAccessEnabled doesn't exist on old JDKs 16 | return null 17 | } 18 | val os = System.getProperty("os.name") 19 | return when { 20 | os.startsWith("Windows") -> TerminalInterfaceFfmWindows() 21 | os == "Linux" -> TerminalInterfaceFfmLinux() 22 | os == "Mac OS X" -> TerminalInterfaceFfmMacos() 23 | else -> null 24 | } 25 | } 26 | } 27 | 28 | @TargetClass(TerminalInterfaceProviderFfm::class) 29 | private class TerminalInterfaceProviderFfmNative { 30 | 31 | @Substitute 32 | fun load(): TerminalInterface? = null 33 | 34 | } 35 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/reflection-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name" : "com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceProviderFfm", 4 | "allDeclaredConstructors" : true, 5 | "allPublicConstructors" : true, 6 | "allDeclaredMethods" : false, 7 | "allPublicMethods" : false, 8 | "allDeclaredFields" : false, 9 | "allPublicFields" : false 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-ffm/resource-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": [ 3 | {"pattern": "META-INF/services/.*"} 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /mordant-jvm-ffm/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider: -------------------------------------------------------------------------------- 1 | com.github.ajalt.mordant.terminal.terminalinterface.ffm.TerminalInterfaceProviderFfm 2 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/README.md: -------------------------------------------------------------------------------- 1 | # Mordant JVM GraalVM FFI 2 | 3 | This is a JVM-only module that adds a `TerminalInterface` implementation for use with GraalVM Native 4 | Image. It doesn't support regular JRE runtimes. 5 | 6 | ```kotlin 7 | implementation("com.github.ajalt.mordant:mordant-jvm-graal-ffi:$mordantVersion") 8 | ``` 9 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/api/mordant-jvm-graal-ffi.api: -------------------------------------------------------------------------------- 1 | public final class com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterfaceProviderNativeImage : com/github/ajalt/mordant/terminal/TerminalInterfaceProvider { 2 | public fun ()V 3 | public fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface; 4 | } 5 | 6 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-kotlin-conventions") 3 | id("mordant-publishing-conventions") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | sourceSets { 9 | commonMain.dependencies { 10 | implementation(project(":mordant")) 11 | } 12 | jvmMain.dependencies { 13 | compileOnly(libs.graalvm.svm) 14 | } 15 | } 16 | compilerOptions { 17 | freeCompilerArgs.addAll( 18 | "-Xno-param-assertions", 19 | "-Xno-call-assertions", 20 | "-Xno-receiver-assertions", 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant-jvm-graal-ffi 2 | POM_NAME=Mordant GraalVM Foreign Function Interface 3 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/nativeimage/TerminalInterfaceProvider.nativeimage.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface.nativeimage 2 | 3 | import com.github.ajalt.mordant.terminal.TerminalInterface 4 | import com.github.ajalt.mordant.terminal.TerminalInterfaceProvider 5 | import com.oracle.svm.core.annotate.Substitute 6 | import com.oracle.svm.core.annotate.TargetClass 7 | import org.graalvm.nativeimage.Platform 8 | import org.graalvm.nativeimage.Platforms 9 | 10 | class TerminalInterfaceProviderNativeImage : TerminalInterfaceProvider { 11 | override fun load(): TerminalInterface? = null 12 | } 13 | 14 | @Platforms(Platform.LINUX::class) 15 | @TargetClass(TerminalInterfaceProviderNativeImage::class) 16 | private class TerminalInterfaceProviderNativeImageLinux { 17 | 18 | @Substitute 19 | fun load(): TerminalInterface = TerminalInterfaceNativeImageLinux() 20 | 21 | } 22 | 23 | @Platforms(Platform.WINDOWS::class) 24 | @TargetClass(TerminalInterfaceProviderNativeImage::class) 25 | private class TerminalInterfaceProviderNativeImageWindows { 26 | 27 | @Substitute 28 | fun load(): TerminalInterface = TerminalInterfaceNativeImageWindows() 29 | 30 | } 31 | 32 | @Platforms(Platform.MACOS::class) 33 | @TargetClass(TerminalInterfaceProviderNativeImage::class) 34 | private class TerminalInterfaceProviderNativeImageMacos { 35 | 36 | @Substitute 37 | fun load(): TerminalInterface = TerminalInterfaceNativeImageMacos() 38 | 39 | } 40 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/reflection-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name" : "com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceProviderNativeImage", 4 | "allDeclaredConstructors" : true, 5 | "allPublicConstructors" : true, 6 | "allDeclaredMethods" : false, 7 | "allPublicMethods" : false, 8 | "allDeclaredFields" : false, 9 | "allPublicFields" : false 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-graal-ffi/resource-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": [ 3 | {"pattern": "META-INF/services/.*"} 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/proguard/mordant-jvm-graal-ffi.pro: -------------------------------------------------------------------------------- 1 | # Keep rules for those who are using ProGuard. 2 | 3 | -dontwarn org.graalvm.** 4 | -dontwarn com.oracle.svm.core.annotate.Delete 5 | -------------------------------------------------------------------------------- /mordant-jvm-graal-ffi/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider: -------------------------------------------------------------------------------- 1 | com.github.ajalt.mordant.terminal.terminalinterface.nativeimage.TerminalInterfaceProviderNativeImage 2 | -------------------------------------------------------------------------------- /mordant-jvm-jna/README.md: -------------------------------------------------------------------------------- 1 | # Mordant JVM JNA 2 | 3 | This is a JVM-only module that adds a `TerminalInterface` implementation that uses [JNA]. This 4 | module supports all JDK versions, but links to a bundled native library, so it increases your JAR 5 | size. 6 | 7 | ```kotlin 8 | implementation("com.github.ajalt.mordant:mordant-jvm-jna:$mordantVersion") 9 | ``` 10 | 11 | [JNA]: https://github.com/java-native-access/jna 12 | -------------------------------------------------------------------------------- /mordant-jvm-jna/api/mordant-jvm-jna.api: -------------------------------------------------------------------------------- 1 | public final class com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterfaceProviderJna : com/github/ajalt/mordant/terminal/TerminalInterfaceProvider { 2 | public fun ()V 3 | public fun load ()Lcom/github/ajalt/mordant/terminal/TerminalInterface; 4 | } 5 | 6 | -------------------------------------------------------------------------------- /mordant-jvm-jna/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-kotlin-conventions") 3 | id("mordant-publishing-conventions") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | sourceSets { 9 | commonMain.dependencies { 10 | implementation(project(":mordant")) 11 | } 12 | jvmMain.dependencies { 13 | implementation(libs.jna.core) 14 | compileOnly(libs.graalvm.svm) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /mordant-jvm-jna/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant-jvm-jna 2 | POM_NAME=Mordant JNA Interface 3 | -------------------------------------------------------------------------------- /mordant-jvm-jna/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/jna/TerminalInterfaceProvider.jna.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface.jna 2 | 3 | import com.github.ajalt.mordant.terminal.TerminalInterface 4 | import com.github.ajalt.mordant.terminal.TerminalInterfaceProvider 5 | import com.oracle.svm.core.annotate.Substitute 6 | import com.oracle.svm.core.annotate.TargetClass 7 | 8 | class TerminalInterfaceProviderJna : TerminalInterfaceProvider { 9 | override fun load(): TerminalInterface? { 10 | // Inlined version of ImageInfo.inImageCode() 11 | val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") 12 | val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" 13 | if (isNativeImage) return null 14 | 15 | val os = System.getProperty("os.name") 16 | return try { 17 | when { 18 | os.startsWith("Windows") -> TerminalInterfaceJnaWindows() 19 | os == "Linux" -> TerminalInterfaceJnaLinux() 20 | os == "Mac OS X" -> TerminalInterfaceJnaMacos() 21 | else -> null 22 | } 23 | } catch (e: UnsatisfiedLinkError) { 24 | null 25 | } 26 | } 27 | } 28 | 29 | @TargetClass(TerminalInterfaceProviderJna::class) 30 | private class TerminalInterfaceProviderJnaNative { 31 | 32 | @Substitute 33 | fun load(): TerminalInterface? = null 34 | 35 | } 36 | -------------------------------------------------------------------------------- /mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/reflection-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name" : "com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceProviderJna", 4 | "allDeclaredConstructors" : true, 5 | "allPublicConstructors" : true, 6 | "allDeclaredMethods" : false, 7 | "allPublicMethods" : false, 8 | "allDeclaredFields" : false, 9 | "allPublicFields" : false 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /mordant-jvm-jna/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant-jvm-jna/resource-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "resources": [ 3 | {"pattern": "META-INF/services/.*"} 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /mordant-jvm-jna/src/jvmMain/resources/META-INF/proguard/mordant-jvm-jna.pro: -------------------------------------------------------------------------------- 1 | # Keep rules for those who are using ProGuard. 2 | 3 | -dontwarn org.graalvm.** 4 | -dontwarn com.oracle.svm.core.annotate.** 5 | -keep class com.sun.jna.** { *; } 6 | -keep class * implements com.sun.jna.** { *; } 7 | -keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,RuntimeVisibleTypeAnnotations,AnnotationDefault 8 | -------------------------------------------------------------------------------- /mordant-jvm-jna/src/jvmMain/resources/META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider: -------------------------------------------------------------------------------- 1 | com.github.ajalt.mordant.terminal.terminalinterface.jna.TerminalInterfaceProviderJna 2 | -------------------------------------------------------------------------------- /mordant-markdown/README.md: -------------------------------------------------------------------------------- 1 | # Mordant Markdown 2 | 3 | This module adds a `Markdown` widget that renders markdown. 4 | -------------------------------------------------------------------------------- /mordant-markdown/api/mordant-markdown.api: -------------------------------------------------------------------------------- 1 | public final class com/github/ajalt/mordant/markdown/Markdown : com/github/ajalt/mordant/rendering/Widget { 2 | public fun (Ljava/lang/String;ZLjava/lang/Boolean;)V 3 | public synthetic fun (Ljava/lang/String;ZLjava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V 4 | public fun measure (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/WidthRange; 5 | public fun render (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/Lines; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /mordant-markdown/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-kotlin-conventions") 3 | id("mordant-js-conventions") 4 | // Need core here pending https://github.com/JetBrains/markdown/pull/159 5 | id("mordant-native-core-conventions") 6 | id("mordant-publishing-conventions") 7 | } 8 | 9 | kotlin { 10 | jvm() // need to list explicitly since we aren't using mpp-conventions for now 11 | sourceSets { 12 | commonMain.dependencies { 13 | api(project(":mordant")) 14 | implementation(libs.markdown) 15 | } 16 | commonTest.dependencies { 17 | implementation(kotlin("test")) 18 | implementation(libs.kotest) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /mordant-markdown/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant-markdown 2 | POM_NAME=Mordant Markdown Rendering 3 | -------------------------------------------------------------------------------- /mordant-markdown/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/BlockQuote.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.markdown 2 | 3 | import com.github.ajalt.mordant.rendering.* 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | 6 | internal class BlockQuote(private val content: Widget) : Widget { 7 | override fun measure(t: Terminal, width: Int): WidthRange { 8 | return content.measure(t, width - 2) + 2 9 | } 10 | 11 | override fun render(t: Terminal, width: Int): Lines { 12 | val bar = Span.word(t.theme.string("markdown.blockquote.bar"), t.theme.style("markdown.blockquote")) 13 | val justBar = Line(listOf(bar)) 14 | val paddedBar = listOf(bar, Span.space(style = t.theme.style("markdown.blockquote"))) 15 | val lines = content.render(t, width).withStyle(t.theme.style("markdown.blockquote")).lines 16 | return Lines(lines.map { if (it.isEmpty()) justBar else Line(paddedBar + it) }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /mordant-markdown/src/commonMain/kotlin/com/github/ajalt/mordant/markdown/Markdown.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.markdown 2 | 3 | import com.github.ajalt.mordant.rendering.Lines 4 | import com.github.ajalt.mordant.rendering.Widget 5 | import com.github.ajalt.mordant.rendering.WidthRange 6 | import com.github.ajalt.mordant.terminal.Terminal 7 | 8 | /** 9 | * A [Widget] that renders a GitHub Flavored [markdown] string. 10 | * 11 | * @property showHtml If `true`, any html tags in the [markdown] will be rendered verbatim to the 12 | * output. By default, html tags are skipped. 13 | * @property hyperlinks If `true`, links will always be rendered with ANSI hyperlinks. If `false`, 14 | * links will always print their targets instead. By default, hyperlinks are used if the current 15 | * terminal supports them. 16 | */ 17 | class Markdown( 18 | private val markdown: String, 19 | private val showHtml: Boolean = false, 20 | private val hyperlinks: Boolean? = null, 21 | ) : Widget { 22 | private var document: Widget? = null 23 | private fun document(t: Terminal): Widget { 24 | if (document == null) { 25 | document = MarkdownRenderer(markdown, t.theme, showHtml, hyperlinks ?: t.terminalInfo.ansiHyperLinks).render() 26 | } 27 | return document!! 28 | } 29 | 30 | override fun measure(t: Terminal, width: Int): WidthRange = document(t).measure(t, width) 31 | override fun render(t: Terminal, width: Int): Lines = document(t).render(t, width) 32 | } 33 | -------------------------------------------------------------------------------- /mordant-omnibus/README.md: -------------------------------------------------------------------------------- 1 | # Mordant Omnibus 2 | 3 | This module is a convenience for depending on `mordant-core` and all of the `mordant-jvm` modules. 4 | 5 | ```kotlin 6 | implementation("com.github.ajalt.mordant:mordant:$mordantVersion") 7 | ``` 8 | -------------------------------------------------------------------------------- /mordant-omnibus/api/mordant-omnibus.api: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/mordant-omnibus/api/mordant-omnibus.api -------------------------------------------------------------------------------- /mordant-omnibus/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl 2 | 3 | // We don't use all the conventions plugins here since applying the mpp=convention results in 4 | // "IllegalStateException: Configuration already finalized for previous property values" 5 | plugins { 6 | kotlin("multiplatform") 7 | id("mordant-native-conventions") 8 | id("mordant-publishing-conventions") 9 | } 10 | 11 | kotlin { 12 | jvm() 13 | js { nodejs() } // we don't have any code, but it's an error not to pick aa JS environment 14 | @OptIn(ExperimentalWasmDsl::class) 15 | wasmJs { nodejs() } 16 | sourceSets { 17 | commonMain.dependencies { 18 | api(project(":mordant")) 19 | } 20 | jvmMain.dependencies { 21 | implementation(project(":mordant-jvm-jna")) 22 | implementation(project(":mordant-jvm-ffm")) 23 | implementation(project(":mordant-jvm-graal-ffi")) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /mordant-omnibus/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant 2 | POM_NAME=Mordant 3 | -------------------------------------------------------------------------------- /mordant-omnibus/src/commonMain/kotlin/com/github/ajalt/mordant/internal/OmibusInternal.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | // This intentionally empty file is a workaround for KT-52344, which fails to publish a target with 4 | // no source files 5 | -------------------------------------------------------------------------------- /mordant/README.md: -------------------------------------------------------------------------------- 1 | # Mordant Core 2 | 3 | This is the core mordant module that implements most Mordant functionality. All other modules depend on this. 4 | 5 | ```kotlin 6 | implementation("com.github.ajalt.mordant:mordant-core:$mordantVersion") 7 | ``` 8 | -------------------------------------------------------------------------------- /mordant/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-conventions") 3 | id("mordant-publishing-conventions") 4 | } 5 | 6 | kotlin { 7 | jvm() 8 | sourceSets { 9 | all { 10 | languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") 11 | languageSettings.optIn("kotlin.experimental.ExperimentalNativeApi") 12 | } 13 | commonMain.dependencies { 14 | api(libs.colormath) 15 | } 16 | commonTest.dependencies { 17 | implementation(kotlin("test")) 18 | implementation(libs.kotest) 19 | } 20 | jvmTest.dependencies { 21 | api(libs.systemrules) 22 | } 23 | // Kotlin 2.0 changed the way MPP is compiled, so instead of copying shared sources to each 24 | // target, it compiles intermediate sources separately. That means that code that previously 25 | // compiled is broken due to errors like "declaration is using numbers with different bit 26 | // widths". So we copy the shared sources to each target manually. 27 | sourceSets { 28 | // https://kotlinlang.org/docs/multiplatform-hierarchy.html#see-the-full-hierarchy-template 29 | val posixMain by creating { dependsOn(nativeMain.get()) } 30 | linuxMain.get().dependsOn(posixMain) 31 | appleMain.get().dependsOn(posixMain) 32 | val appleNonDesktopMain by creating { dependsOn(appleMain.get()) } 33 | for (target in listOf(iosMain, tvosMain, watchosMain)) { 34 | target.get().dependsOn(appleNonDesktopMain) 35 | } 36 | for (target in listOf( 37 | "linuxX64", "linuxArm64", 38 | "macosX64", "macosArm64", 39 | "tvosX64", "tvosArm64", "tvosSimulatorArm64", 40 | "watchosArm32", "watchosArm64", "watchosX64", "watchosSimulatorArm64", 41 | )) { 42 | sourceSets.getByName(target + "Main").kotlin.srcDirs("src/posixSharedMain/kotlin") 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /mordant/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=mordant-core 2 | POM_NAME=Mordant Core 3 | -------------------------------------------------------------------------------- /mordant/src/appleNonDesktopMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.appleNonDesktop.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | internal actual fun testsHaveFileSystem(): Boolean = false 4 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.animation 2 | 3 | import kotlin.time.Duration 4 | import kotlin.time.Duration.Companion.seconds 5 | 6 | interface Refreshable { 7 | /** 8 | * `true` if this animation has finished and should be stopped or cleared. 9 | */ 10 | val finished: Boolean 11 | 12 | /** 13 | * Draw the animation to the screen. 14 | * 15 | * This is called automatically when the animation is running, so you don't usually need to call 16 | * it manually. 17 | * 18 | * @param refreshAll If `true`, refresh all contents, ignoring their fps. 19 | */ 20 | fun refresh(refreshAll: Boolean = false) 21 | } 22 | 23 | /** 24 | * A version of [Animation] that has a parameterless [refresh] method instead of `update`. 25 | * 26 | * Implementations will need to handle concurrently updating their state. 27 | */ 28 | interface RefreshableAnimation : Refreshable, StoppableAnimation { 29 | 30 | /** 31 | * Stop this animation and remove it from the screen. 32 | * 33 | * Future calls to [refresh] will cause the animation to resume. 34 | */ 35 | override fun clear() 36 | 37 | /** 38 | * Stop this animation without removing it from the screen. 39 | * 40 | * Anything printed to the terminal after this call will be printed below this last frame of 41 | * this animation. 42 | * 43 | * Future calls to [refresh] will cause the animation to start again. 44 | */ 45 | override fun stop() 46 | 47 | /** 48 | * The rate, in Hz, that this animation should be refreshed, or 0 if it should not be refreshed 49 | * automatically. 50 | */ 51 | val fps: Int get() = 5 52 | } 53 | 54 | /** The time between refreshes. This is `1 / refreshRate` */ 55 | val RefreshableAnimation.refreshPeriod: Duration 56 | get() = (1.0 / fps).seconds 57 | 58 | /** 59 | * Convert this [Animation] to a [RefreshableAnimation]. 60 | * 61 | * ### Example 62 | * ``` 63 | * terminal.animation {/*...*/}.asRefreshable().animateOnThread() 64 | * ``` 65 | * 66 | * @param fps The rate at which the animation should be refreshed. 67 | * @param finished A function that returns `true` if the animation has finished. 68 | */ 69 | inline fun Animation.asRefreshable( 70 | fps: Int = 5, 71 | crossinline finished: () -> Boolean = { false }, 72 | ): RefreshableAnimation { 73 | return object : RefreshableAnimation { 74 | override fun refresh(refreshAll: Boolean) = update(Unit) 75 | override fun clear() = this@asRefreshable.clear() 76 | override fun stop() = this@asRefreshable.stop() 77 | override val finished: Boolean get() = finished() 78 | override val fps: Int get() = fps 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/StoppableAnimation.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.animation 2 | 3 | interface StoppableAnimation { 4 | /** 5 | * Stop this animation without removing it from the screen. 6 | * 7 | * Anything printed to the terminal after this call will be printed below this last frame of 8 | * this animation. 9 | */ 10 | fun stop() 11 | 12 | /** 13 | * Stop this animation and remove it from the screen. 14 | */ 15 | fun clear() 16 | } 17 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.input 2 | 3 | import com.github.ajalt.mordant.animation.StoppableAnimation 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | 6 | /** 7 | * An object that can receive input events. 8 | */ 9 | interface InputReceiver { 10 | sealed class Status { 11 | companion object { 12 | val Finished = Finished(Unit) 13 | } 14 | 15 | data object Continue : Status() 16 | data class Finished(val result: T) : Status() 17 | } 18 | 19 | /** 20 | * The terminal that this receiver is reading input from. 21 | */ 22 | val terminal: Terminal 23 | 24 | /** 25 | * Receive an input event. 26 | * 27 | * @param event The input event to process 28 | * @return [Status.Continue] to continue receiving events, or [Status.Finished] to stop. 29 | */ 30 | fun receiveEvent(event: InputEvent): Status 31 | } 32 | 33 | /** 34 | * An [InputReceiver] that is also an [StoppableAnimation]. 35 | */ 36 | interface InputReceiverAnimation : InputReceiver, StoppableAnimation 37 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/MouseTracking.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.input 2 | 3 | // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking 4 | enum class MouseTracking { 5 | /** 6 | * Disable mouse tracking 7 | */ 8 | Off, 9 | 10 | /** 11 | * Normal tracking mode sends an escape sequence on both button press and 12 | * release. Modifier key (shift, ctrl, meta) information is also sent. 13 | */ 14 | Normal, 15 | 16 | /** 17 | * Button-event tracking is essentially the same as normal tracking, but 18 | * xterm also reports button-motion events. Motion events are reported 19 | * only if the mouse pointer has moved to a different character cell. 20 | */ 21 | Button, 22 | 23 | /** 24 | * Any-event mode is the same as button-event mode, except that all motion 25 | * events are reported, even if no mouse button is down. 26 | */ 27 | Any 28 | } 29 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/ReceiveEvents.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.input 2 | 3 | import com.github.ajalt.mordant.terminal.Terminal 4 | 5 | /** 6 | * Enter raw mode, read input from the terminal for this [InputReceiver] until it returns a 7 | * result, then exit raw mode. 8 | * 9 | * @param mouseTracking The type of mouse tracking to enable. 10 | * @return the result of the completed receiver. 11 | * @throws RuntimeException if the terminal is not interactive or the input could not be read. 12 | */ 13 | fun InputReceiver.receiveEvents( 14 | mouseTracking: MouseTracking = MouseTracking.Normal, 15 | ): T { 16 | terminal.enterRawMode(mouseTracking).use { rawMode -> 17 | while (true) { 18 | val event = rawMode.readEvent() 19 | when (val status = receiveEvent(event)) { 20 | is InputReceiver.Status.Continue -> continue 21 | is InputReceiver.Status.Finished -> return status.result 22 | } 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Enter raw mode, read input and pass any [KeyboardEvent]s to [block] until it returns a 29 | * result, then exit raw mode. 30 | * 31 | * @return the result of the completed receiver. 32 | * @throws RuntimeException if the terminal is not interactive or the input could not be read. 33 | */ 34 | inline fun Terminal.receiveKeyEvents( 35 | crossinline block: (KeyboardEvent) -> InputReceiver.Status, 36 | ): T { 37 | return receiveEvents(MouseTracking.Off) { event -> 38 | when (event) { 39 | is KeyboardEvent -> block(event) 40 | else -> InputReceiver.Status.Continue 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * Enter raw mode, read input and pass any [MouseEvent]s to [block] until it returns a 47 | * result, then exit raw mode. 48 | * 49 | * @param mouseTracking The type of mouse tracking to enable. 50 | * @return the result of the completed receiver. 51 | * @throws RuntimeException if the terminal is not interactive or the input could not be read. 52 | */ 53 | inline fun Terminal.receiveMouseEvents( 54 | mouseTracking: MouseTracking = MouseTracking.Normal, 55 | crossinline block: (MouseEvent) -> InputReceiver.Status, 56 | ): T { 57 | require(mouseTracking != MouseTracking.Off) { 58 | "Mouse tracking must be enabled to receive mouse events" 59 | } 60 | return receiveEvents(mouseTracking) { event -> 61 | when (event) { 62 | is MouseEvent -> block(event) 63 | else -> InputReceiver.Status.Continue 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Enter raw mode, read input and pass any [InputEvent]s to [block] until it returns a 70 | * result, then exit raw mode. 71 | * 72 | * @param mouseTracking The type of mouse tracking to enable. 73 | * @return the result of the completed receiver. 74 | * @throws RuntimeException if the terminal is not interactive or the input could not be read. 75 | */ 76 | inline fun Terminal.receiveEvents( 77 | mouseTracking: MouseTracking = MouseTracking.Normal, 78 | crossinline block: (InputEvent) -> InputReceiver.Status, 79 | ): T { 80 | return object : InputReceiver { 81 | override val terminal: Terminal get() = this@receiveEvents 82 | override fun receiveEvent(event: InputEvent): InputReceiver.Status { 83 | return block(event) 84 | } 85 | }.receiveEvents(mouseTracking) 86 | } 87 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiCodes.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | @Suppress("ConstPropertyName") 4 | internal object AnsiCodes { 5 | val fg16Range = 30..37 6 | val fg16BrightRange = 90..97 7 | const val fgColorSelector = 38 8 | const val fgColorReset = 39 9 | 10 | const val fgBgOffset = 10 11 | 12 | val bg16Range = 40..47 13 | val bg16BrightRange = 100..107 14 | const val bgColorSelector = 48 15 | const val bgColorReset = 49 16 | 17 | const val selector256 = 5 18 | const val selectorRgb = 2 19 | 20 | const val underlineColorSelector = 58 21 | 22 | const val reset = 0 23 | const val boldOpen = 1 24 | const val boldAndDimClose = 22 25 | const val dimOpen = 2 26 | const val italicOpen = 3 27 | const val italicClose = 23 28 | const val underlineOpen = 4 29 | const val underlineClose = 24 30 | const val inverseOpen = 7 31 | const val inverseClose = 27 32 | const val strikethroughOpen = 9 33 | const val strikethroughClose = 29 34 | } 35 | 36 | internal const val ESC = "\u001B" 37 | 38 | /** Control Sequence Introducer */ 39 | internal const val CSI = "$ESC[" 40 | 41 | /** Operating System Command */ 42 | internal const val OSC = "$ESC]" 43 | 44 | /** String Terminator */ 45 | internal const val ST = "$ESC\\" 46 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/BlankWidgetWrapper.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.rendering.* 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | 6 | /** A widget that displays blank space the same size as an [inner] widget */ 7 | internal class BlankWidgetWrapper(private val inner: Widget) : Widget { 8 | override fun measure(t: Terminal, width: Int): WidthRange = inner.measure(t, width) 9 | 10 | override fun render(t: Terminal, width: Int): Lines { 11 | val orig = inner.render(t, width) 12 | return Lines(orig.lines.map { Line(listOf(Span.space(it.lineWidth))) }) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.rendering.Line 4 | import com.github.ajalt.mordant.rendering.Lines 5 | import com.github.ajalt.mordant.rendering.Span 6 | import com.github.ajalt.mordant.rendering.TextStyle 7 | import com.github.ajalt.mordant.widgets.Padding 8 | 9 | // All top-level vals are defined in this file to avoid initialization order issues on native. 10 | 11 | internal val DEFAULT_STYLE = TextStyle( 12 | color = null, 13 | bgColor = null, 14 | bold = false, 15 | italic = false, 16 | underline = false, 17 | dim = false, 18 | inverse = false, 19 | strikethrough = false, 20 | hyperlink = null, 21 | ) 22 | 23 | internal val EMPTY_LINES = Lines(emptyList()) 24 | internal val EMPTY_LINE: Line = Line(emptyList(), DEFAULT_STYLE) 25 | internal val SINGLE_SPACE = Span.space(1) 26 | internal val DEFAULT_PADDING = Padding(0) 27 | 28 | @Suppress("RegExpRedundantEscape") // JS requires escaping the lone `]` at the beginning of the pattern, so we can't use $OSC 29 | internal val ANSI_RE = Regex("""$ESC\][^$ESC]*$ESC\\|$ESC(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])""") 30 | internal const val HYPERLINK_RESET = "__mordant_reset__" 31 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Formatting.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import kotlin.math.pow 4 | 5 | private const val SI_PREFIXES = "KMGTEPZY" 6 | 7 | /** 8 | * Return a list of all numbers in [nums] formatted as a string, and the unit they were reduced with. 9 | * 10 | * All numbers will be formatted to the same unit. 11 | * 12 | * @param precision The number of decimal places to include in the formatted numbers 13 | * @param nums The numbers to format 14 | */ 15 | internal fun formatMultipleWithSiSuffixes( 16 | precision: Int, truncateDecimals: Boolean, vararg nums: Double, 17 | ): Pair, String> { 18 | require(precision >= 0) { "precision must be >= 0" } 19 | val largest = nums.max() 20 | var divisor = 1 21 | var prefix = "" 22 | for (s in SI_PREFIXES) { 23 | if (largest / divisor < 1000) break 24 | divisor *= 1000 25 | prefix = s.toString() 26 | } 27 | 28 | val exp = 10.0.pow(precision) 29 | val formatted = nums.map { 30 | val n = it / divisor 31 | val i = n.toInt() 32 | val d = ((n - i) * exp).toInt() 33 | when { 34 | truncateDecimals && (precision == 0 || divisor == 1 && d == 0) -> i.toString() 35 | else -> "$i.${d.toString().padEnd(precision, '0')}" 36 | } 37 | } 38 | return formatted to prefix 39 | } 40 | 41 | /** Return this number formatted as a string, suffixed with its SI unit */ 42 | internal fun Double.formatWithSiSuffix(precision: Int): String { 43 | return formatMultipleWithSiSuffixes(precision, false, this).let { it.first.first() + it.second } 44 | } 45 | 46 | /** Return the number of seconds represented by [nanos] as a `Double` */ 47 | internal fun nanosToSeconds(nanos: Double): Double = nanos / 1_000_000_000 48 | 49 | /** Return the number of seconds represented by [nanos] as a `Double` */ 50 | internal fun nanosToSeconds(nanos: Long): Double = nanosToSeconds(nanos.toDouble()) 51 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/HyperlinkIds.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | 4 | private val nextHyperlinkId = MppAtomicInt(1) 5 | 6 | internal fun generateHyperlinkId(): String { 7 | return nextHyperlinkId.getAndIncrement().toString() 8 | } 9 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.* 4 | 5 | internal interface MppAtomicInt { 6 | fun getAndIncrement(): Int 7 | fun get(): Int 8 | fun set(value: Int) 9 | } 10 | 11 | internal interface MppAtomicRef { 12 | val value: T 13 | 14 | /** @return true if the value was set */ 15 | fun compareAndSet(expected: T, newValue: T): Boolean 16 | fun getAndSet(newValue: T): T 17 | } 18 | 19 | /** Update the reference via spin lock, spinning forever until it succeeds. */ 20 | internal inline fun MppAtomicRef.update(block: T.() -> T): Pair { 21 | while (true) { 22 | val old = value 23 | val newValue = block(old) 24 | if (compareAndSet(old, newValue)) return old to newValue 25 | } 26 | } 27 | 28 | internal expect fun MppAtomicRef(value: T): MppAtomicRef 29 | 30 | internal expect fun MppAtomicInt(initial: Int): MppAtomicInt 31 | 32 | internal expect fun getEnv(key: String): String? 33 | 34 | internal expect fun runningInIdeaJavaAgent(): Boolean 35 | 36 | internal expect fun codepointSequence(string: String): Sequence 37 | 38 | internal expect fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor 39 | 40 | internal expect fun printStderr(message: String, newline: Boolean) 41 | 42 | internal expect fun readLineOrNullMpp(hideInput: Boolean): String? 43 | 44 | internal expect fun sendInterceptedPrintRequest( 45 | request: PrintRequest, 46 | terminalInterface: TerminalInterface, 47 | interceptors: List, 48 | ) 49 | 50 | internal expect val CR_IMPLIES_LF: Boolean 51 | 52 | internal expect fun exitProcessMpp(status: Int) 53 | 54 | internal expect fun readFileIfExists(filename: String): String? 55 | 56 | /** Whether tests running on this platform can access the filesystem */ 57 | internal expect fun testsHaveFileSystem(): Boolean 58 | 59 | internal expect fun getStandardTerminalInterface(): TerminalInterface 60 | 61 | internal val STANDARD_TERM_INTERFACE: TerminalInterface = getStandardTerminalInterface() 62 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/ThemeValue.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.rendering.TextStyle 4 | import com.github.ajalt.mordant.rendering.Theme 5 | import com.github.ajalt.mordant.terminal.Terminal 6 | 7 | internal sealed class ThemeStyle { 8 | companion object { 9 | fun of(key: String, explicit: TextStyle?, default: TextStyle = DEFAULT_STYLE): ThemeStyle { 10 | return when (explicit) { 11 | null -> Default(key, default) 12 | else -> Explicit(explicit) 13 | } 14 | } 15 | } 16 | 17 | abstract operator fun get(theme: Theme): TextStyle 18 | operator fun get(terminal: Terminal): TextStyle = get(terminal.theme) 19 | 20 | class Default(private val key: String, private val default: TextStyle) : ThemeStyle() { 21 | override fun get(theme: Theme): TextStyle = theme.style(key, default) 22 | } 23 | 24 | class Explicit(private val style: TextStyle) : ThemeStyle() { 25 | override fun get(theme: Theme): TextStyle = style 26 | } 27 | } 28 | 29 | internal sealed class ThemeString { 30 | companion object { 31 | fun of(key: String, explicit: String?, default: String = ""): ThemeString { 32 | return when (explicit) { 33 | null -> Default(key, default) 34 | else -> Explicit(explicit) 35 | } 36 | } 37 | } 38 | 39 | abstract operator fun get(theme: Theme): String 40 | operator fun get(terminal: Terminal) = get(terminal.theme) 41 | 42 | class Default(private val key: String, private val default: String) : ThemeString() { 43 | override fun get(theme: Theme): String = theme.string(key, default) 44 | } 45 | 46 | class Explicit(private val style: String) : ThemeString() { 47 | override fun get(theme: Theme): String = style 48 | } 49 | } 50 | 51 | internal sealed class ThemeFlag { 52 | companion object { 53 | fun of(key: String, explicit: Boolean?, default: Boolean = false): ThemeFlag { 54 | return when (explicit) { 55 | null -> Default(key, default) 56 | else -> Explicit(explicit) 57 | } 58 | } 59 | } 60 | 61 | abstract operator fun get(theme: Theme): Boolean 62 | operator fun get(terminal: Terminal) = get(terminal.theme) 63 | 64 | class Default(private val key: String, private val default: Boolean) : ThemeFlag() { 65 | override fun get(theme: Theme): Boolean = theme.flag(key, default) 66 | } 67 | 68 | class Explicit(private val style: Boolean) : ThemeFlag() { 69 | override fun get(theme: Theme): Boolean = style 70 | } 71 | } 72 | 73 | internal sealed class ThemeDimension { 74 | companion object { 75 | fun of(key: String, explicit: Int?, default: Int = 0): ThemeDimension { 76 | return when (explicit) { 77 | null -> Default(key, default) 78 | else -> Explicit(explicit) 79 | } 80 | } 81 | } 82 | 83 | abstract operator fun get(theme: Theme): Int 84 | operator fun get(terminal: Terminal) = get(terminal.theme) 85 | 86 | class Default(private val key: String, private val default: Int) : ThemeDimension() { 87 | override fun get(theme: Theme): Int = theme.dimension(key, default) 88 | } 89 | 90 | class Explicit(private val style: Int) : ThemeDimension() { 91 | override fun get(theme: Theme): Int = style 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | /** Read bytes from a UTF-8 encoded stream, and return the next codepoint. */ 4 | internal fun readBytesAsUtf8(readByte: () -> Int?): Int? { 5 | val byte = readByte() ?: return null 6 | val byteLength: Int 7 | var codepoint: Int 8 | when { 9 | byte and 0b1000_0000 == 0x00 -> { 10 | return byte // 1-byte character 11 | } 12 | 13 | byte and 0b1110_0000 == 0b1100_0000 -> { 14 | codepoint = byte and 0b11111 15 | byteLength = 2 16 | } 17 | 18 | byte and 0b1111_0000 == 0b1110_0000 -> { 19 | codepoint = byte and 0b1111 20 | byteLength = 3 21 | } 22 | 23 | byte and 0b1111_1000 == 0b1111_0000 -> { 24 | codepoint = byte and 0b111 25 | byteLength = 4 26 | } 27 | 28 | else -> error("Invalid UTF-8 byte") 29 | } 30 | 31 | repeat(byteLength - 1) { 32 | val next = readByte() ?: return null 33 | if (next and 0b1100_0000 != 0b1000_0000) error("Invalid UTF-8 byte") 34 | codepoint = codepoint shl 6 or (next and 0b0011_1111) 35 | } 36 | return codepoint 37 | } 38 | 39 | /** Convert a unicode codepoint to a String. */ 40 | internal fun codepointToString(codePoint: Int): String { 41 | return when (codePoint) { 42 | in 0..0xFFFF -> { 43 | Char(codePoint).toString() 44 | } 45 | in 0x10000..0x10FFFF -> { 46 | val highSurrogate = Char(((codePoint - 0x10000) shr 10) or 0xD800) 47 | val lowSurrogate = Char(((codePoint - 0x10000) and 0x3FF) or 0xDC00) 48 | highSurrogate.toString() + lowSurrogate.toString() 49 | } 50 | else -> { 51 | throw IllegalArgumentException("Invalid code point: $codePoint") 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/cellwidth.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.internal.gen.CELL_WIDTH_TABLE 4 | import com.github.ajalt.mordant.internal.gen.EMOJI_SEQUENCES 5 | import com.github.ajalt.mordant.internal.gen.IntTrie 6 | import com.github.ajalt.mordant.internal.gen.couldStartEmojiSeq 7 | 8 | 9 | /* 10 | * This implementation uses a binary search of a lookup table, similar to Markus Kuhn's classic C 11 | * implementation of wcwidth. This function differs from his in a few ways. We generate the lookup 12 | * table from the latest unicode standard. wcwidth effectively requires two codespace searches for 13 | * each codepoint. We perform at most a single binary search, and for ASCII characters, we don't 14 | * perform any search at all. wcwidth also returns -1 for most control codes, which is wrong for all 15 | * the use cases you'd use wcwidth for. A BEL character does not suddenly make your line of text 16 | * shorter. We return 0 for control codes other than DEL and BS. 17 | */ 18 | 19 | /** 20 | * Return the width, in terminal cells, of the given unicode [codepoint]. 21 | */ 22 | internal fun cellWidth(codepoint: Int): Int { 23 | if (codepoint in 0x20..0x7e) return 1 // fast path for printable ASCII 24 | if (codepoint == 0x08 || codepoint == 0x7f) return -1 // DEL and BS 25 | 26 | val table = CELL_WIDTH_TABLE 27 | var min = 0 28 | var mid: Int 29 | var max = table.lastIndex 30 | 31 | if (codepoint < table[0].low || codepoint > table[max].high) return 1 32 | 33 | while (max >= min) { 34 | mid = (min + max) / 2 35 | val entry = table[mid] 36 | when { 37 | codepoint > entry.high -> min = mid + 1 38 | codepoint < entry.low -> max = mid - 1 39 | else -> return table[mid].width.toInt() 40 | } 41 | } 42 | 43 | return 1 44 | } 45 | 46 | /** Return the width, in terminal cells, of the given [string]*/ 47 | internal fun stringCellWidth(string: String): Int { 48 | var sum = 0 49 | var sumSinceZwj = 0 50 | var zwjSeq: IntTrie? = null 51 | for (codepoint in codepointSequence(string)) { 52 | val width = cellWidth(codepoint) 53 | if (zwjSeq != null) { 54 | sumSinceZwj += width 55 | if (codepoint in zwjSeq.values) { 56 | sumSinceZwj = 0 57 | } 58 | zwjSeq = zwjSeq.children[codepoint] 59 | if (zwjSeq == null) { 60 | // all ZWJ sequences combine to one glyph, which is always an emoji, so add 2 for the width of the 61 | // emoji, plus the width of any codepoints since the end of the last complete sequence. Unfortunately, 62 | // some of these emoji are wider than two cells, but given that their size is font-dependant and usually 63 | // not cell-aligned anyway, there's no perfect solution here. Thanks, unicode. 64 | sum += sumSinceZwj + 2 65 | sumSinceZwj = 0 66 | } else { 67 | sumSinceZwj += width 68 | } 69 | } else { 70 | // We do a fast range check to skip ZWJ sequence processing for most codepoints 71 | if (couldStartEmojiSeq(codepoint)) { 72 | zwjSeq = EMOJI_SEQUENCES.children[codepoint] 73 | } 74 | if (zwjSeq == null) { 75 | sum += width 76 | } 77 | } 78 | } 79 | // If we were in a zwj sequence at the end of the string, add whatever was left to the sum 80 | return sum + sumSinceZwj 81 | 82 | } 83 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/gen/emojiseqtable.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal.gen 2 | 3 | internal fun couldStartEmojiSeq(codepoint: Int): Boolean { 4 | return codepoint in 0x261d..0x2764 || codepoint in 0x1f344..0x1faf8 5 | } 6 | 7 | internal data class IntTrie( 8 | val children: MutableMap, 9 | val values: MutableSet = mutableSetOf(), 10 | ) { 11 | constructor(vararg children: Pair, values: MutableSet = mutableSetOf()) 12 | : this(mutableMapOf(*children), values) 13 | } 14 | 15 | internal val EMOJI_SEQUENCES: IntTrie = buildSeqTrie() 16 | 17 | private fun buildSeqTrie(): IntTrie { 18 | val root = IntTrie() 19 | for (sequences in arrayOf(sequences1(), sequences2(), sequences3(), sequences4())) { 20 | for (seq in sequences) { 21 | var node = root 22 | for (i in 0.." 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Whitespace.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | /** 4 | * Settings for handling of whitespace and line wrapping. 5 | * 6 | * These values correspond to the values of the CSS 7 | * [white-space](https://developer.mozilla.org/en-US/docs/Web/CSS/white-space) property. 8 | * 9 | * @property collapseNewlines If true, line breaks in the text will be replaced with spaces. 10 | * @property collapseSpaces If true, consecutive spaces will be replaced with a single space. 11 | * @property wrap If true, lines will be broken by replacing spaces with line breaks where necessary 12 | * to keep lines under the configured width. 13 | * @property trimEol If true, whitespace at the end of lines will be removed. 14 | * @see OverflowWrap 15 | */ 16 | enum class Whitespace( 17 | val collapseNewlines: Boolean, 18 | val collapseSpaces: Boolean, 19 | val wrap: Boolean, 20 | val trimEol: Boolean, 21 | ) { 22 | /** Wrap text and collapse all whitespaces and line breaks */ 23 | NORMAL(collapseNewlines = true, collapseSpaces = true, wrap = true, trimEol = true), 24 | 25 | /** Collapse spaces and line breaks, but don't wrap lines. This will effectively put all text on a single line. */ 26 | NOWRAP(collapseNewlines = true, collapseSpaces = true, wrap = false, trimEol = true), 27 | 28 | /** Make no changes to the input text */ 29 | PRE(collapseNewlines = false, collapseSpaces = false, wrap = false, trimEol = false), 30 | 31 | /** Like [PRE], but will break long lines */ 32 | PRE_WRAP(collapseNewlines = false, collapseSpaces = false, wrap = true, trimEol = true), 33 | 34 | /** Like [NORMAL], but preserves any line breaks from the input */ 35 | PRE_LINE(collapseNewlines = false, collapseSpaces = true, wrap = true, trimEol = true) 36 | } 37 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Widget.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.terminal.Terminal 4 | 5 | interface Widget { 6 | fun measure(t: Terminal, width: Int = t.size.width): WidthRange 7 | fun render(t: Terminal, width: Int = t.size.width): Lines 8 | } 9 | 10 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/WidthRange.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.terminal.Terminal 4 | 5 | /** 6 | * @property min The minimum width that a widget needs to render without truncation 7 | * @property max The width that a widget would use if given all available space 8 | */ 9 | data class WidthRange(val min: Int, val max: Int) { 10 | init { 11 | require(min <= max) { "Range min cannot be larger than max" } 12 | } 13 | 14 | operator fun plus(extra: Int) = if (extra == 0) this else WidthRange(min + extra, max + extra) 15 | operator fun plus(other: WidthRange) = WidthRange(min + other.min, max + other.max) 16 | operator fun div(divisor: Int) = 17 | if (divisor == 1) this else WidthRange(min / divisor, max / divisor) 18 | } 19 | 20 | internal fun Iterable.maxWidthRange( 21 | t: Terminal, 22 | width: Int, 23 | paddingWidth: Int = 0, 24 | ): WidthRange { 25 | return maxWidthRange(paddingWidth) { it.measure(t, width - paddingWidth) } 26 | } 27 | 28 | internal inline fun Iterable.maxWidthRange( 29 | paddingWidth: Int = 0, 30 | mapping: (T) -> WidthRange?, 31 | ): WidthRange { 32 | var max = 0 33 | var min = 0 34 | for (it in this) { 35 | val range = mapping(it) ?: continue 36 | max = maxOf(max, range.max) 37 | min = maxOf(min, range.min) 38 | } 39 | return WidthRange(min + paddingWidth, max + paddingWidth) 40 | } 41 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/Borders.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.table 2 | 3 | enum class Borders( 4 | val left: Boolean, 5 | val top: Boolean, 6 | val right: Boolean, 7 | val bottom: Boolean, 8 | ) { 9 | NONE(left = false, top = false, right = false, bottom = false), 10 | BOTTOM(left = false, top = false, right = false, bottom = true), 11 | RIGHT(left = false, top = false, right = true, bottom = false), 12 | BOTTOM_RIGHT(left = false, top = false, right = true, bottom = true), 13 | TOP(left = false, top = true, right = false, bottom = false), 14 | TOP_BOTTOM(left = false, top = true, right = false, bottom = true), 15 | TOP_RIGHT(left = false, top = true, right = true, bottom = false), 16 | TOP_RIGHT_BOTTOM(left = false, top = true, right = true, bottom = true), 17 | LEFT(left = true, top = false, right = false, bottom = false), 18 | LEFT_BOTTOM(left = true, top = false, right = false, bottom = true), 19 | LEFT_RIGHT(left = true, top = false, right = true, bottom = false), 20 | LEFT_RIGHT_BOTTOM(left = true, top = false, right = true, bottom = true), 21 | LEFT_TOP(left = true, top = true, right = false, bottom = false), 22 | LEFT_TOP_BOTTOM(left = true, top = true, right = false, bottom = true), 23 | LEFT_TOP_RIGHT(left = true, top = true, right = true, bottom = false), 24 | ALL(left = true, top = true, right = true, bottom = true), 25 | } 26 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/table/VerticalLayout.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.table 2 | 3 | import com.github.ajalt.mordant.internal.DEFAULT_STYLE 4 | import com.github.ajalt.mordant.internal.EMPTY_LINE 5 | import com.github.ajalt.mordant.rendering.* 6 | import com.github.ajalt.mordant.rendering.TextAlign.NONE 7 | import com.github.ajalt.mordant.table.ColumnWidth.Companion.Auto 8 | import com.github.ajalt.mordant.terminal.Terminal 9 | 10 | private class VerticalLayoutCell( 11 | val content: Widget, 12 | val style: TextStyle?, 13 | val textAlign: TextAlign, 14 | ) 15 | 16 | internal class VerticalLayout private constructor( 17 | private val cells: List, 18 | private val spacing: Int, 19 | private val columnWidth: ColumnWidth, 20 | private val textAlign: TextAlign, 21 | private val hasAlignedCells: Boolean, 22 | ) : Widget { 23 | companion object { 24 | fun fromTableBuilder( 25 | builder: TableBuilderInstance, 26 | spacing: Int, 27 | columnWidth: ColumnWidth, 28 | ): VerticalLayout { 29 | builder.padding(0) 30 | builder.cellBorders = Borders.NONE 31 | builder.tableBorders = Borders.NONE 32 | var aligned = false 33 | val cells = TableLayout(builder).buildTable().rows.map { 34 | check(it.size == 1) 35 | val cell = it[0] as Cell.Content 36 | aligned = aligned || (cell.textAlign !in listOf(null, NONE)) 37 | VerticalLayoutCell(cell.content, cell.style, cell.textAlign) 38 | } 39 | return VerticalLayout( 40 | cells, 41 | spacing, 42 | columnWidth, 43 | builder.align ?: NONE, 44 | aligned, 45 | ) 46 | } 47 | } 48 | 49 | init { 50 | require(spacing >= 0) { "layout spacing cannot be negative" } 51 | } 52 | 53 | override fun measure(t: Terminal, width: Int): WidthRange { 54 | return cells.maxWidthRange { it.content.measure(t, width) } 55 | } 56 | 57 | override fun render(t: Terminal, width: Int): Lines { 58 | val renderWidth = when { 59 | columnWidth.isExpand -> width 60 | hasAlignedCells -> measure(t, width).max 61 | else -> width 62 | }.coerceAtMost(width) 63 | val lines = mutableListOf() 64 | val spacingLine = when (textAlign) { 65 | NONE -> EMPTY_LINE 66 | else -> Line(listOf(Span.space(renderWidth)), DEFAULT_STYLE) 67 | } 68 | for ((i, cell) in cells.withIndex()) { 69 | if (i > 0) repeat(spacing) { lines += spacingLine } 70 | var rendered = cell.content.render(t, renderWidth).withStyle(cell.style) 71 | 72 | val w = columnWidth 73 | rendered = when { 74 | w.expandWeight != null -> rendered.setSize(width, textAlign = cell.textAlign) 75 | w.width != null -> rendered.setSize(w.width, textAlign = cell.textAlign) 76 | cell.textAlign != NONE -> rendered.setSize(renderWidth, textAlign = cell.textAlign) 77 | else -> rendered 78 | } 79 | // Cells always take up a line, even if empty 80 | lines += rendered.lines.ifEmpty { listOf(EMPTY_LINE) } 81 | } 82 | return Lines(lines) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/StandardTerminalInterface.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | import com.github.ajalt.mordant.internal.printStderr 4 | import com.github.ajalt.mordant.internal.readLineOrNullMpp 5 | import com.github.ajalt.mordant.rendering.AnsiLevel 6 | 7 | /** 8 | * A base class for terminal interfaces that prints using standard `kotlin.io` functions. 9 | */ 10 | abstract class StandardTerminalInterface : TerminalInterface { 11 | override fun info( 12 | ansiLevel: AnsiLevel?, 13 | hyperlinks: Boolean?, 14 | outputInteractive: Boolean?, 15 | inputInteractive: Boolean?, 16 | ): TerminalInfo { 17 | return TerminalDetection.detectTerminal( 18 | ansiLevel = ansiLevel, 19 | hyperlinks = hyperlinks, 20 | forceInputInteractive = inputInteractive, 21 | forceOutputInteractive = outputInteractive, 22 | detectedStdinInteractive = stdinInteractive(), 23 | detectedStdoutInteractive = stdoutInteractive(), 24 | ) 25 | } 26 | 27 | override fun completePrintRequest(request: PrintRequest) { 28 | when { 29 | request.stderr -> printStderr(request.text, request.trailingLinebreak) 30 | request.trailingLinebreak -> { 31 | if (request.text.isEmpty()) { 32 | println() 33 | } else { 34 | println(request.text) 35 | } 36 | } 37 | 38 | else -> print(request.text) 39 | } 40 | } 41 | 42 | override fun readLineOrNull(hideInput: Boolean): String? = readLineOrNullMpp(hideInput) 43 | 44 | /** Whether the output stream is detected as interactive. */ 45 | open fun stdoutInteractive(): Boolean = true 46 | 47 | /** Whether the input stream is detected as interactive. */ 48 | open fun stdinInteractive(): Boolean = true 49 | } 50 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalExtensions.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | import com.github.ajalt.mordant.rendering.OverflowWrap 4 | import com.github.ajalt.mordant.rendering.TextAlign 5 | import com.github.ajalt.mordant.rendering.Whitespace 6 | 7 | /** 8 | * Print a line styled with the theme's [danger][Theme.danger] style. 9 | */ 10 | fun Terminal.danger( 11 | message: Any?, 12 | whitespace: Whitespace = Whitespace.PRE, 13 | align: TextAlign = TextAlign.NONE, 14 | overflowWrap: OverflowWrap = OverflowWrap.NORMAL, 15 | width: Int? = null, 16 | stderr: Boolean = false, 17 | ) { 18 | println(theme.danger(message.toString()), whitespace, align, overflowWrap, width, stderr) 19 | } 20 | 21 | /** 22 | * Print a line styled with the theme's [warning][Theme.warning] style. 23 | */ 24 | fun Terminal.warning( 25 | message: Any?, 26 | whitespace: Whitespace = Whitespace.PRE, 27 | align: TextAlign = TextAlign.NONE, 28 | overflowWrap: OverflowWrap = OverflowWrap.NORMAL, 29 | width: Int? = null, 30 | stderr: Boolean = false, 31 | ) { 32 | println(theme.warning(message.toString()), whitespace, align, overflowWrap, width, stderr) 33 | } 34 | 35 | /** 36 | * Print a line styled with the theme's [info][Theme.info] style. 37 | */ 38 | fun Terminal.info( 39 | message: Any?, 40 | whitespace: Whitespace = Whitespace.PRE, 41 | align: TextAlign = TextAlign.NONE, 42 | overflowWrap: OverflowWrap = OverflowWrap.NORMAL, 43 | width: Int? = null, 44 | stderr: Boolean = false, 45 | ) { 46 | println(theme.info(message.toString()), whitespace, align, overflowWrap, width, stderr) 47 | } 48 | 49 | /** 50 | * Print a line styled with the theme's [muted][Theme.muted] style. 51 | */ 52 | fun Terminal.muted( 53 | message: Any?, 54 | whitespace: Whitespace = Whitespace.PRE, 55 | align: TextAlign = TextAlign.NONE, 56 | overflowWrap: OverflowWrap = OverflowWrap.NORMAL, 57 | width: Int? = null, 58 | stderr: Boolean = false, 59 | ) { 60 | println(theme.muted(message.toString()), whitespace, align, overflowWrap, width, stderr) 61 | } 62 | 63 | /** 64 | * Print a line styled with the theme's [success][Theme.success] style. 65 | */ 66 | fun Terminal.success( 67 | message: Any?, 68 | whitespace: Whitespace = Whitespace.PRE, 69 | align: TextAlign = TextAlign.NONE, 70 | overflowWrap: OverflowWrap = OverflowWrap.NORMAL, 71 | width: Int? = null, 72 | stderr: Boolean = false, 73 | ) { 74 | println(theme.success(message.toString()), whitespace, align, overflowWrap, width, stderr) 75 | } 76 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInfo.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | import com.github.ajalt.mordant.rendering.AnsiLevel 4 | 5 | 6 | /** 7 | * Information about the current terminal 8 | */ 9 | data class TerminalInfo( 10 | /** The level of ANSI codes to use when printing to the terminal */ 11 | var ansiLevel: AnsiLevel, 12 | /** If true, ANSI hyperlink codes can be used */ 13 | var ansiHyperLinks: Boolean, 14 | /** 15 | * If false the output stream is not an interactive terminal, such as when it's redirected to a 16 | * file. 17 | */ 18 | val outputInteractive: Boolean, 19 | /** 20 | * If false the intput stream is not an interactive terminal, such as when it's redirected from 21 | * a file 22 | */ 23 | val inputInteractive: Boolean, 24 | /** 25 | * If false using ANSI cursor movement codes may not work. 26 | */ 27 | val supportsAnsiCursor: Boolean, 28 | ) { 29 | /** Return true if both input and output are interactive */ 30 | val interactive: Boolean get() = inputInteractive && outputInteractive 31 | } 32 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterceptor.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | internal fun interface TerminalInterceptor { 4 | fun intercept(request: PrintRequest): PrintRequest 5 | } 6 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterfaceProvider.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | /** 4 | * A provider for a [TerminalInterface]. 5 | * 6 | * Implementations of this interface are loaded via the `ServiceLoader` mechanism, and should be 7 | * declared in the `META-INF/services/com.github.ajalt.mordant.terminal.TerminalInterfaceProvider` 8 | * file. 9 | */ 10 | interface TerminalInterfaceProvider { 11 | /** 12 | * Load the terminal interface. 13 | * 14 | * @return The terminal interface, or null if it could not be loaded. 15 | */ 16 | fun load(): TerminalInterface? 17 | } 18 | 19 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Caption.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.rendering.* 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | 6 | /** Add a [top] and/or [bottom] caption to [content] */ 7 | class Caption( 8 | val content: Widget, 9 | val top: Widget? = null, 10 | val bottom: Widget? = null, 11 | ) : Widget { 12 | constructor( 13 | content: Widget, 14 | top: String? = null, 15 | bottom: String? = null, 16 | topAlign: TextAlign = TextAlign.CENTER, 17 | bottomAlign: TextAlign = TextAlign.CENTER, 18 | ) : this( 19 | content, 20 | top?.let { Text(it, align = topAlign) }, 21 | bottom?.let { Text(it, align = bottomAlign) }, 22 | ) 23 | 24 | override fun measure(t: Terminal, width: Int): WidthRange { 25 | return content.measure(t, width) 26 | } 27 | 28 | override fun render(t: Terminal, width: Int): Lines { 29 | val captionWidth = content.measure(t, width).max.coerceAtMost(width) 30 | val lines = mutableListOf() 31 | top?.let { lines.addAll(it.render(t, captionWidth).lines) } 32 | lines.addAll(content.render(t, width).lines) 33 | bottom?.let { lines.addAll(it.render(t, captionWidth).lines) } 34 | return Lines(lines) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/EmptyWidget.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.internal.EMPTY_LINES 4 | import com.github.ajalt.mordant.rendering.Widget 5 | import com.github.ajalt.mordant.rendering.WidthRange 6 | import com.github.ajalt.mordant.terminal.Terminal 7 | 8 | /** 9 | * A widget with 0 width, 0 height, and no content. 10 | * 11 | * Can be used as a placeholder in layouts etc. 12 | */ 13 | object EmptyWidget : Widget { 14 | override fun measure(t: Terminal, width: Int) = WidthRange(0, 0) 15 | override fun render(t: Terminal, width: Int) = EMPTY_LINES 16 | } 17 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/OrderedList.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.internal.* 4 | import com.github.ajalt.mordant.rendering.* 5 | import com.github.ajalt.mordant.terminal.Terminal 6 | import kotlin.math.log10 7 | 8 | /** 9 | * A numbered list of widgets. 10 | * 11 | * @property listEntries The content of the list 12 | * @property numberStyle The style of the list numbers. Defaults to the theme value `list.number`. 13 | * @property numberSeparator The string to print between the list numbers and the widgets. Defaults to the theme value 14 | * `list.number.separator` 15 | */ 16 | class OrderedList private constructor( 17 | private val listEntries: List, 18 | private val numberStyle: ThemeStyle, 19 | private val numberSeparator: ThemeString, 20 | ) : Widget { 21 | constructor( 22 | listEntries: List, 23 | numberStyle: TextStyle? = null, 24 | numberSeparator: String? = null, 25 | ) : this( 26 | listEntries, 27 | ThemeStyle.of("list.number", numberStyle), 28 | ThemeString.of("list.number.separator", numberSeparator) 29 | ) 30 | 31 | private fun sep(t: Theme): Line { 32 | val text = numberSeparator[t] 33 | require("\n" !in text) { "number separator cannot contain newlines" } 34 | return parseText( 35 | text, 36 | numberStyle[t] 37 | ).lines.firstOrNull() ?: EMPTY_LINE 38 | } 39 | 40 | private fun continuationPadding(i: Int, sepWidth: Int): List { 41 | val n = bulletWidth(i, sepWidth) 42 | return listOf(Span.space(n)) 43 | } 44 | 45 | private fun bulletWidth(i: Int, sepWidth: Int): Int { 46 | return (log10((i + 1).toDouble()).toInt() + 1 // number 47 | + 2 // padding 48 | + sepWidth 49 | ) 50 | } 51 | 52 | private val maxBulletWidth = bulletWidth(listEntries.size, sep(Theme.Default).lineWidth) 53 | 54 | override fun measure(t: Terminal, width: Int): WidthRange { 55 | return listEntries.maxWidthRange(t, width, maxBulletWidth) 56 | } 57 | 58 | override fun render(t: Terminal, width: Int): Lines { 59 | val contentWidth = width - maxBulletWidth 60 | val lines = mutableListOf() 61 | val style = numberStyle[t] 62 | val sep = sep(t.theme) 63 | val sepWidth = sep.lineWidth 64 | 65 | for ((i, entry) in listEntries.withIndex()) { 66 | val bullet = flatLine( 67 | SINGLE_SPACE, 68 | Span.word("${i + 1}", style), 69 | sep, 70 | SINGLE_SPACE 71 | ) 72 | for ((j, line) in entry.render(t, contentWidth).lines.withIndex()) { 73 | val start = if (j == 0) bullet else continuationPadding(i, sepWidth) 74 | lines += Line(start + line) 75 | } 76 | } 77 | return Lines(lines) 78 | } 79 | } 80 | 81 | fun OrderedList( 82 | vararg listEntries: String, 83 | numberStyle: TextStyle? = null, 84 | numberSeparator: String? = null, 85 | ): OrderedList { 86 | return OrderedList(listEntries.map { Text(it) }, numberStyle, numberSeparator) 87 | } 88 | 89 | fun OrderedList( 90 | vararg listEntries: Widget, 91 | numberStyle: TextStyle? = null, 92 | numberSeparator: String? = null, 93 | ): OrderedList { 94 | return OrderedList(listEntries.toList(), numberStyle, numberSeparator) 95 | } 96 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Spinner.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.internal.DEFAULT_STYLE 4 | import com.github.ajalt.mordant.internal.MppAtomicInt 5 | import com.github.ajalt.mordant.rendering.Lines 6 | import com.github.ajalt.mordant.rendering.TextStyle 7 | import com.github.ajalt.mordant.rendering.Widget 8 | import com.github.ajalt.mordant.rendering.WidthRange 9 | import com.github.ajalt.mordant.terminal.Terminal 10 | 11 | /** 12 | * A widget that will loop through a fixed list of frames. 13 | * 14 | * The widget will render the same frame until [tick] is set or [advanceTick] is called, at which point the next frame 15 | * will render. The animation will loop when [tick] is larger than the number frames. 16 | * 17 | * To reduce the speed of the animation, increase the [duration]. 18 | * 19 | * @property duration the number of [ticks][tick] that each frame of the spinner should show for. This defaults to 1, 20 | * which will cause a new frame to display every time [advanceTick] is called. 21 | * @param initial the starting tick value. 22 | */ 23 | class Spinner( 24 | private val frames: List, 25 | private val duration: Int = 1, 26 | initial: Int = 0, 27 | ) : Widget { 28 | companion object { 29 | /** 30 | * Create a spinner with the following frames: 31 | * 32 | * `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` 33 | */ 34 | fun Dots(style: TextStyle = DEFAULT_STYLE, duration: Int = 1, initial: Int = 0): Spinner { 35 | return Spinner("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", style, duration, initial) 36 | } 37 | 38 | /** 39 | * Create a spinner with the following frames: 40 | * 41 | * `|/-\` 42 | * 43 | * These frames only use ASCII characters. 44 | */ 45 | fun Lines(style: TextStyle = DEFAULT_STYLE, duration: Int = 1, initial: Int = 0): Spinner { 46 | return Spinner("|/-\\", style, duration, initial) 47 | } 48 | } 49 | 50 | /** 51 | * Construct a [Spinner] from a string, where each character in the string is one frame. 52 | */ 53 | constructor( 54 | frames: String, 55 | style: TextStyle = DEFAULT_STYLE, 56 | duration: Int = 1, 57 | initial: Int = 0, 58 | ) : 59 | this(frames.map { Text(style(it.toString())) }, duration, initial) 60 | 61 | private val _tick = MppAtomicInt(initial) 62 | 63 | /** 64 | * The current frame number. 65 | * 66 | * This may be larger than the number of frames, in which case the animation will loop. 67 | */ 68 | var tick: Int 69 | get() = _tick.get() 70 | set(value) { 71 | _tick.set(value) 72 | } 73 | 74 | /** 75 | * Increment the [tick] value by one and return the new value. 76 | */ 77 | fun advanceTick(): Int { 78 | return _tick.getAndIncrement() + 1 79 | } 80 | 81 | /** The current frame */ 82 | val currentFrame: Widget get() = frames[(tick / duration) % frames.size] 83 | 84 | override fun measure(t: Terminal, width: Int): WidthRange = currentFrame.measure(t, width) 85 | override fun render(t: Terminal, width: Int): Lines = currentFrame.render(t, width) 86 | } 87 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/UnorderedList.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.internal.* 4 | import com.github.ajalt.mordant.rendering.* 5 | import com.github.ajalt.mordant.terminal.Terminal 6 | 7 | class UnorderedList private constructor( 8 | private val listEntries: List, 9 | private val bulletText: ThemeString, 10 | private val bulletStyle: ThemeStyle, 11 | ) : Widget { 12 | constructor( 13 | listEntries: List, 14 | bulletText: String? = null, 15 | bulletStyle: TextStyle? = null, 16 | ) : this( 17 | listEntries, 18 | ThemeString.of("list.bullet.text", bulletText), 19 | ThemeStyle.of("list.bullet", bulletStyle) 20 | ) 21 | 22 | private fun bullet(t: Theme): Line { 23 | val text = bulletText[t] 24 | require("\n" !in text) { "bullet text cannot contain newlines" } 25 | if (text.isEmpty()) return EMPTY_LINE 26 | return flatLine( 27 | SINGLE_SPACE, 28 | parseText(text, bulletStyle[t]).lines.firstOrNull() ?: EMPTY_LINE, 29 | SINGLE_SPACE 30 | ) 31 | } 32 | 33 | override fun measure(t: Terminal, width: Int): WidthRange { 34 | val bulletWidth = bullet(t.theme).sumOf { it.cellWidth } 35 | return listEntries.maxWidthRange(t, width, bulletWidth) 36 | } 37 | 38 | override fun render(t: Terminal, width: Int): Lines { 39 | val bullet = bullet(t.theme) 40 | val bulletWidth = bullet.sumOf { it.cellWidth } 41 | val contentWidth = (width - bulletWidth).coerceAtLeast(0) 42 | val continuationPadding = when { 43 | bulletWidth > 0 -> listOf(Span.space(bulletWidth, bulletStyle[t.theme])) 44 | else -> EMPTY_LINE 45 | } 46 | 47 | val lines = mutableListOf() 48 | 49 | for (entry in listEntries) { 50 | for ((i, line) in entry.render(t, contentWidth).lines.withIndex()) { 51 | val start = if (i == 0) bullet else continuationPadding 52 | lines += Line(start + line) 53 | } 54 | } 55 | return Lines(lines) 56 | } 57 | } 58 | 59 | fun UnorderedList( 60 | vararg listEntries: String, 61 | bulletText: String? = null, 62 | bulletStyle: TextStyle? = null, 63 | ): UnorderedList = UnorderedList( 64 | listEntries.map { Text(it) }, 65 | bulletText, 66 | bulletStyle, 67 | ) 68 | 69 | fun UnorderedList( 70 | vararg listEntries: Widget, 71 | bulletText: String? = null, 72 | bulletStyle: TextStyle? = null, 73 | ): UnorderedList = UnorderedList( 74 | listEntries.toList(), 75 | bulletText, 76 | bulletStyle, 77 | ) 78 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/Viewport.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.rendering.Lines 4 | import com.github.ajalt.mordant.rendering.Widget 5 | import com.github.ajalt.mordant.rendering.WidthRange 6 | import com.github.ajalt.mordant.rendering.setSize 7 | import com.github.ajalt.mordant.terminal.Terminal 8 | 9 | 10 | /** 11 | * Crop or pad another [content] widget to a fixed size, and optionally scroll visible portion of 12 | * the widget within that size. 13 | * 14 | * If [width] or [height] are larger than the size of the [content] widget, the extra space will be 15 | * filled with spaces. 16 | * 17 | * You can specify [scrollRight] and [scrollDown] to scroll the viewport across the content. 18 | * Negative values will scroll the widget to the right or up, respectively. 19 | * 20 | * ### Example 21 | * 22 | * ``` 23 | * val text = Text( 24 | * """ 25 | * 123 26 | * 456 27 | * 7890 28 | * """.trimIndent() 29 | * ) 30 | * 31 | * val viewport1 = Viewport(text, width = 2, height = 2, scrollRight = 1, scrollDown = 1) 32 | * terminal.println(Panel(viewport1)) 33 | * 34 | * val viewport2 = Viewport(text, width = 2, height = 2, scrollRight = -1, scrollDown = -1) 35 | * terminal.println(Panel(viewport2)) 36 | * ``` 37 | * 38 | * Will print the following: 39 | * 40 | * ``` 41 | * ╭──╮ 42 | * │56│ 43 | * │89│ 44 | * ╰──╯ 45 | * ╭──╮ 46 | * │ │ 47 | * │ 1│ 48 | * ╰──╯ 49 | * ``` 50 | */ 51 | class Viewport( 52 | /** The widget to crop. */ 53 | private val content: Widget, 54 | /** The width to crop the widget to, or `null` to use the width of the longest line */ 55 | private val width: Int?, 56 | /** The height to crop the widget to, or `null` to leave the height unchanged */ 57 | private val height: Int? = null, 58 | /** 59 | * The number of characters to crop from the left of the [content] (or from the right, if 60 | * negative) 61 | */ 62 | private val scrollRight: Int = 0, 63 | /** 64 | * The number of lines to crop from the top of the [content] (or from the bottom, if negative) 65 | */ 66 | private val scrollDown: Int = 0, 67 | ) : Widget { 68 | override fun measure(t: Terminal, width: Int): WidthRange { 69 | return if (this.width != null) { 70 | WidthRange(this.width, this.width) 71 | } else { 72 | content.measure(t, width) 73 | } 74 | } 75 | 76 | override fun render(t: Terminal, width: Int): Lines { 77 | val lines = content.render(t, width) 78 | return lines.setSize( 79 | newWidth = this.width ?: lines.width, 80 | newHeight = this.height ?: lines.lines.size, 81 | scrollRight = scrollRight, 82 | scrollDown = scrollDown 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/progress/CachedProgressBarDefinition.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets.progress 2 | 3 | import com.github.ajalt.mordant.internal.MppAtomicRef 4 | import com.github.ajalt.mordant.internal.update 5 | import com.github.ajalt.mordant.rendering.Widget 6 | import kotlin.time.ComparableTimeMark 7 | import kotlin.time.Duration.Companion.seconds 8 | import kotlin.time.TimeSource 9 | 10 | 11 | /** 12 | * A [ProgressBarDefinition] that caches the widgets for each cell so that they only update as fast 13 | * as their [fps][ProgressBarCell.fps]. 14 | */ 15 | class CachedProgressBarDefinition( 16 | definition: ProgressBarDefinition, 17 | private val timeSource: TimeSource.WithComparableMarks, 18 | ) : ProgressBarDefinition { 19 | private val cache = MppAtomicRef>>(emptyMap()) 20 | override val cells: List> = 21 | definition.cells.mapIndexed { i, it -> makeCell(i, it) } 22 | override val spacing: Int = definition.spacing 23 | override val alignColumns: Boolean = definition.alignColumns 24 | 25 | /** 26 | * Invalidate the cache for this definition. 27 | */ 28 | fun invalidateCache() { 29 | cache.getAndSet(emptyMap()) 30 | } 31 | 32 | /** 33 | * The refresh rate, Hz, that will satisfy the [fps][ProgressBarCell.fps] of 34 | * all this progress bar's cells. 35 | */ 36 | val fps: Int = definition.cells.maxOfOrNull { it.fps } ?: 0 37 | 38 | // Wrap the cell builder in a block that caches the widget 39 | private fun makeCell( 40 | i: Int, 41 | cell: ProgressBarCell, 42 | ): ProgressBarCell { 43 | return ProgressBarCell(cell.columnWidth, cell.fps, cell.align, cell.verticalAlign) { 44 | val (_, new) = cache.update { 45 | when { 46 | isCacheValid(cell, this[i]?.first) -> this 47 | else -> { 48 | val content = cell.content(this@ProgressBarCell) 49 | this + (i to (timeSource.markNow() to content)) 50 | } 51 | } 52 | } 53 | 54 | new[i]?.second ?: cell.content(this) 55 | } 56 | } 57 | 58 | private fun isCacheValid( 59 | cell: ProgressBarCell, lastFrameTime: ComparableTimeMark?, 60 | ): Boolean { 61 | if (lastFrameTime == null) return false 62 | val timeSinceLastFrame = lastFrameTime.elapsedNow() 63 | // if fps is 0 this will be Infinity, so it will be cached forever 64 | val maxCacheRetentionDuration = (1.0 / cell.fps).seconds 65 | return timeSinceLastFrame < maxCacheRetentionDuration 66 | } 67 | } 68 | 69 | /** 70 | * Cache this progress bar definition so that each cell only updates as often as its 71 | * [fps][ProgressBarCell.fps]. 72 | */ 73 | fun ProgressBarDefinition.cache( 74 | timeSource: TimeSource.WithComparableMarks = TimeSource.Monotonic, 75 | ): CachedProgressBarDefinition { 76 | return when (this) { 77 | is CachedProgressBarDefinition -> this 78 | else -> CachedProgressBarDefinition(this, timeSource) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/InteractiveSelectListTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.input 2 | 3 | import com.github.ajalt.mordant.rendering.AnsiLevel 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | import com.github.ajalt.mordant.terminal.TerminalRecorder 6 | import com.github.ajalt.mordant.widgets.SelectList.Entry 7 | import io.kotest.matchers.shouldBe 8 | import io.kotest.matchers.string.shouldContain 9 | import kotlin.js.JsName 10 | import kotlin.test.Test 11 | 12 | class InteractiveSelectListTest { 13 | private val rec = TerminalRecorder( 14 | width = 24, ansiLevel = AnsiLevel.NONE, inputInteractive = true, outputInteractive = true 15 | ) 16 | private val t = Terminal(terminalInterface = rec) 17 | 18 | @[Test JsName("single_select_strings")] 19 | fun `single select strings`() = doSingleSelectTest { 20 | t.interactiveSelectList(listOf("a", "b", "c")) 21 | } 22 | 23 | @[Test JsName("single_select_entries")] 24 | fun `single select entries`() = doSingleSelectTest { 25 | t.interactiveSelectList(listOf(Entry("a"), Entry("b"), Entry("c"))) 26 | } 27 | 28 | @[Test JsName("multi_select_strings")] 29 | fun `multi select strings`() = doMultiSelectTest { 30 | t.interactiveMultiSelectList(listOf("a", "b", "c")) 31 | } 32 | 33 | @[Test JsName("multi_select_entries")] 34 | fun `multi select entries`() = doMultiSelectTest { 35 | t.interactiveMultiSelectList(listOf(Entry("a"), Entry("b"), Entry("c"))) 36 | } 37 | 38 | private fun doSingleSelectTest(runList: () -> String?) { 39 | rec.inputEvents = mutableListOf(KeyboardEvent("ArrowDown"), KeyboardEvent("Enter")) 40 | runList() shouldBe "b" 41 | rec.stdout() shouldContain """ 42 | ░❯ a 43 | ░ b 44 | ░ c 45 | """.trimMargin("░") 46 | } 47 | 48 | private fun doMultiSelectTest(runList: () -> List?) { 49 | rec.inputEvents = mutableListOf( 50 | KeyboardEvent("ArrowDown"), 51 | KeyboardEvent("x"), 52 | KeyboardEvent("ArrowDown"), 53 | KeyboardEvent("x"), 54 | KeyboardEvent("Enter"), 55 | ) 56 | runList() shouldBe listOf("b", "c") 57 | rec.stdout() shouldContain """ 58 | ░❯ • a 59 | ░ • b 60 | ░ • c 61 | """.trimMargin("░") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/platform/MultiplatformSystemTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.platform 2 | 3 | import com.github.ajalt.mordant.internal.testsHaveFileSystem 4 | import io.kotest.matchers.nulls.shouldNotBeNull 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.string.shouldNotBeEmpty 7 | import kotlin.test.Test 8 | 9 | 10 | class MultiplatformSystemTest { 11 | @Test 12 | fun readEnvironmentVariable() { 13 | // The kotlin.test plugin doesn't provide a way to set environment variables that works on 14 | // all targets, so just pick a common one that should exist everywhere. 15 | val actual = MultiplatformSystem.readEnvironmentVariable("PATH") 16 | if (!testsHaveFileSystem()) return 17 | actual.shouldNotBeNull().shouldNotBeEmpty() 18 | } 19 | 20 | @Test 21 | fun readFileAsUtf8() { 22 | val actual = MultiplatformSystem.readFileAsUtf8( 23 | // Most targets have a cwd of $moduleDir 24 | "src/commonTest/resources/multiplatform_system_test.txt" 25 | ) ?: MultiplatformSystem.readFileAsUtf8( 26 | // js targets have a cwd of $projectDir/build/js/packages/mordant-mordant-test 27 | "../../../../mordant/src/commonTest/resources/multiplatform_system_test.txt" 28 | ) 29 | if (!testsHaveFileSystem()) return 30 | actual?.trim() shouldBe "pass" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/rendering/TextAlignmentTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.rendering.TextAlign.* 4 | import com.github.ajalt.mordant.rendering.TextColors.blue 5 | import com.github.ajalt.mordant.rendering.TextColors.white 6 | import com.github.ajalt.mordant.test.RenderingTest 7 | import com.github.ajalt.mordant.widgets.Text 8 | import kotlin.js.JsName 9 | import kotlin.test.Test 10 | 11 | 12 | class TextAlignmentTest : RenderingTest() { 13 | @[Test JsName("align_none")] 14 | fun `align none`() = doTest( 15 | NONE, 79, """ 16 | ░one_word 17 | ░ 18 | ░two words 19 | ░3 whole words 20 | ░four words 4 4 21 | ░5 5 5 5 5 22 | """ 23 | ) 24 | 25 | @[Test JsName("align_left")] 26 | fun `align left`() = doTest( 27 | LEFT, 15, """ 28 | ░one_word ░ 29 | ░ ░ 30 | ░two words ░ 31 | ░3 whole words ░ 32 | ░four words 4 4 ░ 33 | ░5 5 5 5 5 ░ 34 | """ 35 | ) 36 | 37 | @[Test JsName("align_right")] 38 | fun `align right`() = doTest( 39 | RIGHT, 15, """ 40 | ░ one_word░ 41 | ░ ░ 42 | ░ two words░ 43 | ░ 3 whole words░ 44 | ░ four words 4 4░ 45 | ░ 5 5 5 5 5░ 46 | """ 47 | ) 48 | 49 | @[Test JsName("align_center")] 50 | fun `align center`() = doTest( 51 | CENTER, 15, """ 52 | ░ one_word ░ 53 | ░ ░ 54 | ░ two words ░ 55 | ░ 3 whole words ░ 56 | ░four words 4 4 ░ 57 | ░ 5 5 5 5 5 ░ 58 | """ 59 | ) 60 | 61 | @[Test JsName("align_justify")] 62 | fun `align justify`() = doTest( 63 | JUSTIFY, 15, """ 64 | ░ one_word ░ 65 | ░ ░ 66 | ░two words░ 67 | ░3 whole words░ 68 | ░four words 4 4░ 69 | ░5 5 5 5 5░ 70 | """ 71 | ) 72 | 73 | @[Test JsName("align_justify_wide")] 74 | fun `align justify wide`() = doTest( 75 | JUSTIFY, 21, """ 76 | ░ one_word ░ 77 | ░ ░ 78 | ░two words░ 79 | ░3 whole words░ 80 | ░four words 4 4░ 81 | ░5 5 5 5 5░ 82 | """ 83 | ) 84 | 85 | private fun doTest(align: TextAlign, width: Int, expected: String) { 86 | val ex = expected.trimMargin("░").lines().joinToString("\n") { (blue on white)(it) } 87 | val text = """ 88 | ░one_word 89 | ░ 90 | ░two words 91 | ░3 whole words 92 | ░four words 4 4 93 | ░5 5 5 5 5 94 | """.trimMargin("░") 95 | checkRender( 96 | Text( 97 | (blue on white)(text), 98 | whitespace = Whitespace.PRE_WRAP, 99 | align = align, 100 | ), ex, width = width 101 | ) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/rendering/TextOverflowWrapTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.test.RenderingTest 4 | import com.github.ajalt.mordant.widgets.Text 5 | import kotlin.test.Test 6 | 7 | 8 | class TextOverflowWrapTest : RenderingTest() { 9 | @Test 10 | fun normal() = doTest( 11 | """ 12 | ░The weather today is 13 | ░21°C in 14 | ░Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch 15 | """, OverflowWrap.NORMAL 16 | ) 17 | 18 | @Test 19 | fun break_word() = doTest( 20 | """ 21 | ░The weather today is 22 | ░21°C in 23 | ░Llanfairpwllgwyngyllgog 24 | ░erychwyrndrobwllllantys 25 | ░iliogogogoch 26 | """, OverflowWrap.BREAK_WORD 27 | ) 28 | 29 | @Test 30 | fun truncate() = doTest( 31 | """ 32 | ░The weather today is 33 | ░21°C in 34 | ░Llanfairpwllgwyngyllgog 35 | """, OverflowWrap.TRUNCATE 36 | ) 37 | 38 | @Test 39 | fun ellipses() = doTest( 40 | """ 41 | ░The weather today is 42 | ░21°C in 43 | ░Llanfairpwllgwyngyllgo… 44 | """, OverflowWrap.ELLIPSES 45 | ) 46 | 47 | @Test 48 | fun nowrap() = doTest( 49 | """ 50 | ░The weather today is 21°C in Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch 51 | """, OverflowWrap.ELLIPSES, Whitespace.NOWRAP 52 | ) 53 | 54 | private fun doTest( 55 | expected: String, 56 | wrap: OverflowWrap, 57 | whitespace: Whitespace = Whitespace.NORMAL, 58 | width: Int = 23, 59 | ) { 60 | val text = """ 61 | ░The weather today is 21°C in 62 | ░Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch 63 | """.trimMargin("░") 64 | val widget = Text( 65 | text, 66 | whitespace = whitespace, 67 | align = TextAlign.NONE, 68 | overflowWrap = wrap 69 | ) 70 | checkRender(widget, expected, width = width) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/rendering/TextStyleOscTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.internal.OSC 4 | import com.github.ajalt.mordant.internal.ST 5 | import com.github.ajalt.mordant.rendering.TextStyles.Companion.hyperlink 6 | import com.github.ajalt.mordant.test.normalizeHyperlinks 7 | import io.kotest.matchers.shouldBe 8 | import kotlin.js.JsName 9 | import kotlin.test.Test 10 | 11 | class TextStyleOscTest { 12 | @[Test JsName("single_hyperlink")] 13 | fun `single hyperlink`() = doTest( 14 | hyperlink("foo.com")("bar"), 15 | "<;id=1;foo.com>bar<;;>" 16 | ) 17 | 18 | @[Test JsName("nested_hyperlink")] 19 | fun `nested hyperlink`() = doTest( 20 | hyperlink("foo")("bar${hyperlink("baz")("qux")}qor"), 21 | "<;id=1;foo>bar<;id=2;baz>qux<;id=1;foo>qor<;;>" 22 | ) 23 | 24 | private fun doTest(actual: String, expected: String) { 25 | val normalized = actual.replace("${OSC}8", "<").replace(ST, ">").normalizeHyperlinks() 26 | try { 27 | normalized shouldBe expected 28 | } catch (e: Throwable) { 29 | println(normalized) 30 | throw e 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/rendering/TextWhitespaceTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.rendering.TextAlign.NONE 4 | import com.github.ajalt.mordant.rendering.Whitespace.* 5 | import com.github.ajalt.mordant.test.RenderingTest 6 | import com.github.ajalt.mordant.widgets.Text 7 | import kotlin.js.JsName 8 | import kotlin.test.Test 9 | 10 | 11 | class TextWhitespaceTest : RenderingTest() { 12 | @Test 13 | fun normal() = doTest( 14 | NORMAL, 18, """ 15 | ░Lorem ipsum dolor░ 16 | ░sit amet,░ 17 | ░consectetur░ 18 | ░adipiscing elit, 19 | ░sed░ 20 | """ 21 | ) 22 | 23 | @Test 24 | fun nowrap() = doTest( 25 | NOWRAP, 18, """ 26 | ░Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed░ 27 | """ 28 | ) 29 | 30 | @Test 31 | fun pre() = doTest( 32 | PRE, 19, """ 33 | ░Lorem ipsum dolor ░ 34 | ░░ 35 | ░sit amet, consectetur ░ 36 | ░ adipiscing ░ 37 | ░elit, sed░ 38 | """ 39 | ) 40 | 41 | @Test 42 | fun pre_wrap() = doTest( 43 | PRE_WRAP, 19, """ 44 | ░Lorem ipsum dolor░ 45 | ░░ 46 | ░sit amet,░ 47 | ░consectetur░ 48 | ░ adipiscing░ 49 | ░elit, sed░ 50 | """ 51 | ) 52 | 53 | @Test 54 | fun pre_line() = doTest( 55 | PRE_LINE, 19, """ 56 | ░Lorem ipsum dolor░ 57 | ░░ 58 | ░sit amet,░ 59 | ░consectetur░ 60 | ░adipiscing░ 61 | ░elit, sed░ 62 | """ 63 | ) 64 | 65 | @[Test JsName("consecutive_whitespace_spans")] 66 | fun `consecutive whitespace spans`() { 67 | val line1 = Line(listOf("a", " ", " ").map { Span.word(it) }) 68 | val line2 = Line(listOf(" ", "b").map { Span.word(it) }) 69 | checkRender( 70 | Text(Lines(listOf(line1, line2)), whitespace = PRE_WRAP), 71 | """ 72 | ░a 73 | ░ b 74 | """, 75 | width = 2 76 | ) 77 | } 78 | 79 | private fun doTest(ws: Whitespace, width: Int, expected: String) { 80 | val text = """ 81 | ░Lorem ipsum dolor ░ 82 | ░░ 83 | ░sit amet, consectetur ░ 84 | ░␉adipiscing ░ 85 | ░elit, sed░ 86 | """.trimMargin("░").replace("░", "").replace("␉", "\t") 87 | checkRender( 88 | Text( 89 | text, 90 | whitespace = ws, 91 | align = NONE, 92 | tabWidth = 4 93 | ), expected, width = width 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/rendering/ThemeTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering 2 | 3 | import com.github.ajalt.mordant.internal.DEFAULT_STYLE 4 | import io.kotest.matchers.shouldBe 5 | import kotlin.test.Test 6 | 7 | class ThemeTest { 8 | private val theme = Theme(Theme.PlainAscii) { 9 | styles["s"] = TextColors.blue 10 | } 11 | 12 | @Test 13 | fun style() { 14 | theme.style("s") shouldBe TextColors.blue 15 | theme.style("foo") shouldBe DEFAULT_STYLE 16 | theme.style("foo", TextColors.blue) shouldBe TextColors.blue 17 | } 18 | 19 | @Test 20 | fun styleOrNull() { 21 | theme.styleOrNull("s") shouldBe TextColors.blue 22 | theme.styleOrNull("foo") shouldBe null 23 | } 24 | 25 | @Test 26 | fun flag() { 27 | theme.flag("markdown.code.block.border") shouldBe true 28 | theme.flag("foo") shouldBe false 29 | theme.flag("foo", true) shouldBe true 30 | } 31 | 32 | @Test 33 | fun flagOrNull() { 34 | theme.flagOrNull("markdown.code.block.border") shouldBe true 35 | theme.flagOrNull("foo") shouldBe null 36 | } 37 | 38 | @Test 39 | fun string() { 40 | theme.string("list.number.separator") shouldBe "." 41 | theme.string("foo") shouldBe "" 42 | theme.string("foo", "bar") shouldBe "bar" 43 | } 44 | 45 | @Test 46 | fun stringOrNull() { 47 | theme.stringOrNull("list.number.separator") shouldBe "." 48 | theme.stringOrNull("foo") shouldBe null 49 | } 50 | 51 | @Test 52 | fun dimension() { 53 | theme.dimension("hr.title.padding") shouldBe 1 54 | theme.dimension("foo") shouldBe 0 55 | theme.dimension("foo", -1) shouldBe -1 56 | } 57 | 58 | @Test 59 | fun dimensionOrNull() { 60 | theme.dimensionOrNull("hr.title.padding") shouldBe 1 61 | theme.dimensionOrNull("foo") shouldBe null 62 | } 63 | 64 | @Test 65 | fun plus() { 66 | val l = Theme { 67 | dimensions["foo"] = 11 68 | dimensions["bar"] = 22 69 | } 70 | val r = Theme { 71 | dimensions["bar"] = 23 72 | dimensions["baz"] = 33 73 | } 74 | val t = l + r 75 | t.dimension("foo") shouldBe 11 76 | t.dimension("bar") shouldBe 23 77 | t.dimension("baz") shouldBe 33 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/rendering/internal/CellWidthTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.rendering.internal 2 | 3 | import com.github.ajalt.mordant.internal.cellWidth 4 | import com.github.ajalt.mordant.internal.codepointSequence 5 | import com.github.ajalt.mordant.internal.stringCellWidth 6 | import io.kotest.data.blocking.forAll 7 | import io.kotest.data.row 8 | import io.kotest.matchers.shouldBe 9 | import kotlin.test.Test 10 | 11 | 12 | internal class CellWidthTest { 13 | 14 | @Test 15 | fun cellWidth() = forAll( 16 | row(" ", 1), 17 | row("a", 1), 18 | row("\n", 0), 19 | row("\u0000", 0), // NUL 20 | row("\u001b", 0), // ESC 21 | row("\u1885", 0), // MONGOLIAN LETTER ALI GALI BALUDA (combining) 22 | row("\u007f", -1), // DEL 23 | row("\u0008", -1), // BS 24 | row("가", 2), // HANGUL SYLLABLE GA 25 | row("ぁ", 2), // HIRAGANA LETTER SMALL A 26 | row("💯", 2), // HUNDRED POINTS SYMBOL 27 | ) { char, width -> 28 | cellWidth(codepointSequence(char).single()) shouldBe width 29 | } 30 | 31 | @Test 32 | fun stringCellWidth() = forAll( 33 | row("", 0), 34 | row("a", 1), 35 | row("\n", 0), 36 | row("1\u007F1", 1), 37 | row("모ㄹ단ㅌ", 8), 38 | row("媒人", 4), 39 | row("🙊🙉🙈", 6), 40 | row("en\u0303e", 3), 41 | row("👍🏿", 2), 42 | row("🇩🇪", 2), 43 | row( 44 | "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB1", 45 | 2 46 | ), // MAN, FITZPATRICK TYPE-5, ZWJ, CURLY HAIR 47 | row( 48 | "\uD83D\uDC69\u200D\uD83D\uDC67", 49 | 2 50 | ), // Emoji_ZWJ_Sequence ; family: woman, girl (👩‍👧) 51 | row( 52 | "\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", 53 | 2 54 | ), //Emoji_ZWJ_Sequence ; family: woman, girl, boy (👩‍👧‍👦) 55 | row( 56 | "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66", 57 | 2 58 | ), // Emoji_ZWJ_Sequence ; family: woman, woman, boy, boy (👩‍👩‍👦‍👦) 59 | 60 | ) { str, width -> 61 | stringCellWidth(str) shouldBe width 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/table/TableBorderTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.table 2 | 3 | import com.github.ajalt.mordant.table.Borders.* 4 | import com.github.ajalt.mordant.test.RenderingTest 5 | import kotlin.test.Test 6 | 7 | @Suppress("TestFunctionName") 8 | class TableBorderTest : RenderingTest() { 9 | @Test 10 | fun NONE() = doTest( 11 | NONE, 12 | """ 13 | ░ × 14 | """ 15 | ) 16 | 17 | @Test 18 | fun BOTTOM() = doTest( 19 | BOTTOM, 20 | """ 21 | ░ × 22 | ░─── 23 | """ 24 | ) 25 | 26 | @Test 27 | fun RIGHT() = doTest( 28 | RIGHT, 29 | """ 30 | ░ × │ 31 | """ 32 | ) 33 | 34 | @Test 35 | fun BOTTOM_RIGHT() = doTest( 36 | BOTTOM_RIGHT, 37 | """ 38 | ░ × │ 39 | ░───┘ 40 | """ 41 | ) 42 | 43 | @Test 44 | fun TOP() = doTest( 45 | TOP, 46 | """ 47 | ░─── 48 | ░ × 49 | """ 50 | ) 51 | 52 | @Test 53 | fun TOP_BOTTOM() = doTest( 54 | TOP_BOTTOM, 55 | """ 56 | ░─── 57 | ░ × 58 | ░─── 59 | """ 60 | ) 61 | 62 | @Test 63 | fun TOP_RIGHT() = doTest( 64 | TOP_RIGHT, 65 | """ 66 | ░───┐ 67 | ░ × │ 68 | """ 69 | ) 70 | 71 | @Test 72 | fun TOP_RIGHT_BOTTOM() = doTest( 73 | TOP_RIGHT_BOTTOM, 74 | """ 75 | ░───┐ 76 | ░ × │ 77 | ░───┘ 78 | """ 79 | ) 80 | 81 | @Test 82 | fun LEFT() = doTest( 83 | LEFT, 84 | """ 85 | ░│ × 86 | """ 87 | ) 88 | 89 | @Test 90 | fun LEFT_BOTTOM() = doTest( 91 | LEFT_BOTTOM, 92 | """ 93 | ░│ × 94 | ░└─── 95 | """ 96 | ) 97 | 98 | @Test 99 | fun LEFT_RIGHT() = doTest( 100 | LEFT_RIGHT, 101 | """ 102 | ░│ × │ 103 | """ 104 | ) 105 | 106 | @Test 107 | fun LEFT_RIGHT_BOTTOM() = doTest( 108 | LEFT_RIGHT_BOTTOM, 109 | """ 110 | ░│ × │ 111 | ░└───┘ 112 | """ 113 | ) 114 | 115 | @Test 116 | fun LEFT_TOP() = doTest( 117 | LEFT_TOP, 118 | """ 119 | ░┌─── 120 | ░│ × 121 | """ 122 | ) 123 | 124 | @Test 125 | fun LEFT_TOP_BOTTOM() = doTest( 126 | LEFT_TOP_BOTTOM, 127 | """ 128 | ░┌─── 129 | ░│ × 130 | ░└─── 131 | """ 132 | ) 133 | 134 | @Test 135 | fun LEFT_TOP_RIGHT() = doTest( 136 | LEFT_TOP_RIGHT, 137 | """ 138 | ░┌───┐ 139 | ░│ × │ 140 | """ 141 | ) 142 | 143 | @Test 144 | fun ALL() = doTest( 145 | ALL, 146 | """ 147 | ░┌───┐ 148 | ░│ × │ 149 | ░└───┘ 150 | """ 151 | ) 152 | 153 | private fun doTest(borders: Borders, expected: String) = checkRender(table { 154 | this.cellBorders = borders 155 | body { 156 | row("×") 157 | } 158 | }, expected) 159 | } 160 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/HtmlRendererTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | import com.github.ajalt.mordant.rendering.TextColors.blue 4 | import com.github.ajalt.mordant.rendering.TextColors.red 5 | import io.kotest.matchers.shouldBe 6 | import kotlin.js.JsName 7 | import kotlin.test.Test 8 | 9 | class HtmlRendererTest { 10 | private val vt = TerminalRecorder() 11 | private val t = Terminal(terminalInterface = vt) 12 | 13 | init { 14 | t.print(red("red red")) 15 | t.println("plain") 16 | t.print(blue("blue blue")) 17 | } 18 | 19 | @[Test JsName("no_frame")] 20 | fun `no frame`() { 21 | vt.outputAsHtml(backgroundColor = null) shouldBe """ 22 | | 23 | |

24 |         |red redplain
25 |         |blue blue
26 |         |
27 | | 28 | """.trimMargin() 29 | } 30 | 31 | @[Test JsName("frame_no_body_tag")] 32 | fun `frame no body tag`() { 33 | vt.outputAsHtml(includeCodeTag = false, includeBodyTag = false) shouldBe """ 34 | |
⏺ ⏺ ⏺ 
35 | |
36 |         |red redplain
37 |         |blue blue
38 |         |
39 | |
40 | """.trimMargin() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/terminal/TerminalTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | import com.github.ajalt.mordant.rendering.TextAlign 4 | import com.github.ajalt.mordant.rendering.TextColors.cyan 5 | import com.github.ajalt.mordant.rendering.Whitespace 6 | import io.kotest.data.blocking.forAll 7 | import io.kotest.data.row 8 | import io.kotest.matchers.shouldBe 9 | import kotlin.js.JsName 10 | import kotlin.test.Test 11 | 12 | class TerminalTest { 13 | private val vt = TerminalRecorder(width = 8) 14 | private val t = Terminal(terminalInterface = vt) 15 | 16 | @Test 17 | fun success() { 18 | t.success("success") 19 | vt.output() shouldBe t.theme.success("success") + "\n" 20 | } 21 | 22 | @Test 23 | fun danger() { 24 | t.danger("danger") 25 | vt.output() shouldBe t.theme.danger("danger") + "\n" 26 | } 27 | 28 | @Test 29 | fun warning() { 30 | t.warning("warning") 31 | vt.output() shouldBe t.theme.warning("warning") + "\n" 32 | } 33 | 34 | @Test 35 | fun info() { 36 | t.info("info") 37 | vt.output() shouldBe t.theme.info("info") + "\n" 38 | } 39 | 40 | @Test 41 | fun muted() { 42 | t.muted("muted") 43 | vt.output() shouldBe t.theme.muted("muted") + "\n" 44 | } 45 | 46 | @Test 47 | fun print() { 48 | t.print("1") 49 | t.print("2", stderr = true) 50 | t.print("3") 51 | vt.stdout() shouldBe "13" 52 | vt.stderr() shouldBe "2" 53 | vt.output() shouldBe "123" 54 | } 55 | 56 | @Test 57 | fun println() { 58 | t.println("1") 59 | t.println("2", stderr = true) 60 | t.println("3") 61 | vt.stdout() shouldBe "1\n3\n" 62 | vt.stderr() shouldBe "2\n" 63 | vt.output() shouldBe "1\n2\n3\n" 64 | } 65 | 66 | @Test 67 | fun rawPrint() { 68 | t.rawPrint(t.cursor.getMoves { left(1) }) 69 | t.rawPrint(t.cursor.getMoves { up(1) }, stderr = true) 70 | t.rawPrint(t.cursor.getMoves { right(1) }) 71 | vt.stdout() shouldBe t.cursor.getMoves { left(1); right(1) } 72 | vt.stderr() shouldBe t.cursor.getMoves { up(1) } 73 | vt.output() shouldBe t.cursor.getMoves { left(1); up(1); right(1) } 74 | 75 | vt.clearOutput() 76 | t.rawPrint("\t") 77 | vt.output() shouldBe "\t" 78 | } 79 | 80 | @[Test JsName("print_customized")] 81 | fun `print customized`() { 82 | t.print(cyan("print with a wrap"), whitespace = Whitespace.NORMAL, align = TextAlign.RIGHT) 83 | vt.output() shouldBe """ 84 | |${cyan(" print")} 85 | |${cyan(" with a")} 86 | |${cyan(" wrap")} 87 | """.trimMargin() 88 | } 89 | 90 | @[Test JsName("width_override")] 91 | fun `width override`() = forAll( 92 | row(1, 2, true, 1), 93 | row(1, 2, false, 2), 94 | row(null, 2, true, 3), 95 | row(null, 2, false, 2), 96 | row(1, null, true, 1), 97 | row(1, null, false, 1), 98 | row(null, null, true, 3), 99 | ) { width, niWidth, interactive, expected -> 100 | val vt = TerminalRecorder(width = 3, outputInteractive = interactive) 101 | val t = Terminal( 102 | terminalInterface = vt, 103 | width = width, 104 | nonInteractiveWidth = niWidth 105 | ) 106 | t.size.width shouldBe expected 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.test 2 | 3 | import com.github.ajalt.mordant.rendering.AnsiLevel 4 | import com.github.ajalt.mordant.rendering.Theme 5 | import com.github.ajalt.mordant.rendering.Widget 6 | import com.github.ajalt.mordant.terminal.Terminal 7 | 8 | abstract class RenderingTest( 9 | private val width: Int = 79, 10 | ) { 11 | protected fun checkRender( 12 | widget: Widget, 13 | expected: String, 14 | trimMargin: Boolean = true, 15 | width: Int = this.width, 16 | height: Int = 24, 17 | tabWidth: Int = 8, 18 | hyperlinks: Boolean = true, 19 | theme: Theme = Theme.Default, 20 | transformActual: (String) -> String = { it }, 21 | ) { 22 | val t = Terminal( 23 | ansiLevel = AnsiLevel.TRUECOLOR, 24 | theme = theme, 25 | width = width, 26 | height = height, 27 | hyperlinks = hyperlinks, 28 | tabWidth = tabWidth 29 | ) 30 | val actual = transformActual(t.render(widget)) 31 | actual.shouldMatchRender(expected, trimMargin) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.test 2 | 3 | import com.github.ajalt.mordant.internal.CR_IMPLIES_LF 4 | import com.github.ajalt.mordant.internal.CSI 5 | import com.github.ajalt.mordant.terminal.TerminalRecorder 6 | import io.kotest.matchers.shouldBe 7 | 8 | fun String.normalizeHyperlinks(): String { 9 | var i = 1 10 | val regex = Regex(";id=([^;]+);") 11 | val map = mutableMapOf() 12 | regex.findAll(this).forEach { map.getOrPut(it.value) { i++ } } 13 | return regex.replace(this) { ";id=${map[it.value]};" } 14 | } 15 | 16 | fun String.visibleCrLf(keepBreaks: Boolean = false): String { 17 | return replace("\r", "␍").replace("\n", if (keepBreaks) "\n" else "␊").replace(CSI, "␛") 18 | } 19 | 20 | private val upMove = Regex("${Regex.escape(CSI)}\\d+A") 21 | 22 | // This handles the difference in wasm movements and the other targets 23 | fun TerminalRecorder.normalizedOutput(): String { 24 | return if (CR_IMPLIES_LF) output().replace("\r${CSI}1A", "\r") else output() 25 | } 26 | 27 | fun TerminalRecorder.latestOutput(): String { 28 | return normalizedOutput() 29 | // remove everything before the last cursor movement 30 | .let { it.split(upMove).lastOrNull() ?: it }.substringAfter("\r") 31 | .replace("${CSI}0J", "") // remove clear screen command 32 | } 33 | 34 | infix fun String.shouldMatchRender(expected: String) = shouldMatchRender(expected, true) 35 | 36 | fun String.shouldMatchRender(expected: String, trimMargin: Boolean) { 37 | try { 38 | val trimmed = if (trimMargin) expected.trimMargin("░") else expected 39 | this shouldBe trimmed.replace("░", "") 40 | } catch (e: Throwable) { 41 | println() 42 | println(this) 43 | throw e 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/HorizontalRuleTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.rendering.TextAlign 4 | import com.github.ajalt.mordant.rendering.TextColors.blue 5 | import com.github.ajalt.mordant.rendering.TextColors.red 6 | import com.github.ajalt.mordant.rendering.Theme 7 | import com.github.ajalt.mordant.rendering.Whitespace 8 | import com.github.ajalt.mordant.test.RenderingTest 9 | import kotlin.js.JsName 10 | import kotlin.test.Test 11 | 12 | class HorizontalRuleTest : RenderingTest() { 13 | @[Test JsName("no_title")] 14 | fun `no title`() { 15 | checkRender(HorizontalRule(), "──────────", width = 10) 16 | } 17 | 18 | @[Test JsName("multiple_character_rules")] 19 | fun `multiple character rules`() { 20 | checkRender( 21 | HorizontalRule(title = "title", ruleCharacter = "1234"), 22 | "123412 title 1234123", 23 | width = 20 24 | ) 25 | } 26 | 27 | @[Test JsName("rule_with_whitespace")] 28 | fun `rule with whitespace`() { 29 | checkRender(HorizontalRule(ruleCharacter = "- -"), "- -- -", width = 6) 30 | } 31 | 32 | @[Test JsName("title_align_left")] 33 | fun `title align left`() { 34 | checkRender( 35 | HorizontalRule("title", titleAlign = TextAlign.LEFT), 36 | "─ title ────", 37 | width = 12 38 | ) 39 | } 40 | 41 | @[Test JsName("title_align_right")] 42 | fun `title align right`() { 43 | checkRender( 44 | HorizontalRule("title", titleAlign = TextAlign.RIGHT), 45 | "──── title ─", 46 | width = 12 47 | ) 48 | } 49 | 50 | @[Test JsName("multiline_title")] 51 | fun `multiline title`() { 52 | checkRender( 53 | HorizontalRule( 54 | title = Text( 55 | "Multiline\nHeader Text", 56 | whitespace = Whitespace.PRE_WRAP 57 | ) 58 | ), 59 | """ 60 | ░ Multiline ░ 61 | ░─── Header Text ───░ 62 | """, 63 | width = 19 64 | ) 65 | } 66 | 67 | @[Test JsName("styled_title_and_rule")] 68 | fun `styled title and rule`() { 69 | checkRender( 70 | HorizontalRule(title = blue("title"), ruleStyle = blue), 71 | blue("─── title ───"), width = 13 72 | ) 73 | } 74 | 75 | @[Test JsName("themed_title_and_rule")] 76 | fun `themed title and rule`() { 77 | checkRender( 78 | HorizontalRule(title = blue("title")), 79 | red("── ${blue("title")} ──"), 80 | width = 13, 81 | theme = Theme { 82 | styles["hr.rule"] = red 83 | dimensions["hr.title.padding"] = 2 84 | } 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/OrderedListTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.test.RenderingTest 4 | import kotlin.js.JsName 5 | import kotlin.test.Test 6 | 7 | class OrderedListTest : RenderingTest() { 8 | @[Test JsName("vararg_string_constructor")] 9 | fun `vararg string constructor`() = checkRender( 10 | OrderedList("one", "two", "three"), 11 | """ 12 | ░ 1. one 13 | ░ 2. two 14 | ░ 3. three 15 | """ 16 | ) 17 | 18 | @[Test JsName("vararg_widget_constructor")] 19 | fun `vararg widget constructor`() = checkRender( 20 | OrderedList(Text("one"), Text("two"), Text("three")), 21 | """ 22 | ░ 1. one 23 | ░ 2. two 24 | ░ 3. three 25 | """ 26 | ) 27 | 28 | @[Test JsName("empty_list")] 29 | fun `empty list`() = checkRender( 30 | OrderedList(emptyList()), 31 | "" 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/PaddingTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.test.RenderingTest 4 | import kotlin.test.Test 5 | 6 | class PaddingTest : RenderingTest() { 7 | private val w = Text("x") 8 | 9 | @Test 10 | fun all() = checkRender( 11 | w.withPadding(1), """ 12 | ░░ 13 | ░ x ░ 14 | ░░ 15 | """ 16 | ) 17 | 18 | @Test 19 | fun fourValues() = checkRender( 20 | w.withPadding(1, 2, 3, 4), """ 21 | ░░ 22 | ░ x ░ 23 | ░░ 24 | ░░ 25 | ░░ 26 | """ 27 | ) 28 | 29 | @Test 30 | fun vertical() = checkRender( 31 | w.withPadding { vertical = 1 }, """ 32 | ░░ 33 | ░x░ 34 | ░░ 35 | """ 36 | ) 37 | 38 | @Test 39 | fun horizontal() = checkRender( 40 | w.withPadding { horizontal = 1 }, """ 41 | ░ x ░ 42 | """ 43 | ) 44 | 45 | @Test 46 | fun padEmpty() = checkRender( 47 | Text("x\n\ny").withPadding(1, padEmptyLines = true), """ 48 | ░░ 49 | ░ x ░ 50 | ░ ░ 51 | ░ y ░ 52 | ░░ 53 | """ 54 | ) 55 | 56 | @Test 57 | fun noPadEmpty() = checkRender( 58 | Text("x\n\ny").withPadding(1, padEmptyLines = false), """ 59 | ░░ 60 | ░ x ░ 61 | ░░ 62 | ░ y ░ 63 | ░░ 64 | """ 65 | ) 66 | 67 | @Test 68 | fun withTopPadding() = checkRender( 69 | w.withPadding { top = 1 }, """ 70 | ░░ 71 | ░x░ 72 | """ 73 | ) 74 | 75 | @Test 76 | fun withRightPadding() = checkRender( 77 | w.withPadding { right = 1 }, """ 78 | ░x ░ 79 | """ 80 | ) 81 | 82 | @Test 83 | fun withBottomPadding() = checkRender( 84 | w.withPadding { bottom = 1 }, """ 85 | ░x░ 86 | ░░ 87 | """ 88 | ) 89 | 90 | @Test 91 | fun withLeftPadding() = checkRender( 92 | w.withPadding { left = 1 }, """ 93 | ░ x░ 94 | """ 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ProgressBarTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.internal.CSI 4 | import com.github.ajalt.mordant.rendering.Theme 5 | import com.github.ajalt.mordant.test.RenderingTest 6 | import kotlin.js.JsName 7 | import kotlin.test.Test 8 | 9 | class ProgressBarTest : RenderingTest() { 10 | @[Test JsName("_0_percent_complete")] 11 | fun `0 percent complete`() = doPercentTest(0, " ") 12 | 13 | @[Test JsName("_10_percent_complete")] 14 | fun `10 percent complete`() = doPercentTest(10, " ") 15 | 16 | @[Test JsName("_20_percent_complete")] 17 | fun `20 percent complete`() = doPercentTest(20, "#> ") 18 | 19 | @[Test JsName("_30_percent_complete")] 20 | fun `30 percent complete`() = doPercentTest(30, "#> ") 21 | 22 | @[Test JsName("_40_percent_complete")] 23 | fun `40 percent complete`() = doPercentTest(40, "##> ") 24 | 25 | @[Test JsName("_60_percent_complete")] 26 | fun `60 percent complete`() = doPercentTest(60, "###> ") 27 | 28 | @[Test JsName("_80_percent_complete")] 29 | fun `80 percent complete`() = doPercentTest(80, "####>") 30 | 31 | @[Test JsName("_99_percent_complete")] 32 | fun `99 percent complete`() = doPercentTest(99, "####>") 33 | 34 | @[Test JsName("_100_percent_complete")] 35 | fun `100 percent complete`() = doPercentTest(100, "#####") 36 | 37 | @[Test JsName("default_theme")] 38 | fun `default theme`() = doPercentTest( 39 | 40, 40 | "${CSI}38;2;97;175;239m━━${CSI}39m ${CSI}38;2;92;99;112m━━${CSI}39m", 41 | theme = Theme.Default 42 | ) 43 | 44 | @[Test JsName("pulse_initial")] 45 | fun `pulse initial`() = doPulseTest( 46 | pulsePosition = 0f, 47 | "${CSI}38;2;97;175;239m━━━━━━━━━━${CSI}39m" 48 | ) 49 | 50 | @[Test JsName("pulse_25")] 51 | fun `pulse 25`() = doPulseTest( 52 | pulsePosition = .25f, 53 | "${CSI}38;2;97;175;239m━${CSI}38;2;251;253;255m━${CSI}38;2;207;230;251m━${CSI}38;2;107;180;240m━${CSI}38;2;97;175;239m━━━━━━${CSI}39m" 54 | ) 55 | 56 | @[Test JsName("pulse_50")] 57 | fun `pulse 50`() = doPulseTest( 58 | pulsePosition = .50f, 59 | "${CSI}38;2;97;175;239m━━━${CSI}38;2;136;194;244m━${CSI}38;2;239;247;254m━━${CSI}38;2;136;194;244m━${CSI}38;2;97;175;239m━━━${CSI}39m" 60 | ) 61 | 62 | @[Test JsName("pulse_75")] 63 | fun `pulse 75`() = doPulseTest( 64 | pulsePosition = .75f, 65 | "${CSI}38;2;97;175;239m━━━━━━${CSI}38;2;107;180;240m━${CSI}38;2;207;230;251m━${CSI}38;2;254;255;255m━${CSI}38;2;178;216;249m━${CSI}39m" 66 | ) 67 | 68 | @[Test JsName("pulse_100")] 69 | fun `pulse 100`() = doPulseTest( 70 | pulsePosition = 1f, 71 | "${CSI}38;2;97;175;239m━━━━━━━━━━${CSI}39m" 72 | ) 73 | 74 | @[Test JsName("narrow_width")] 75 | fun `narrow width`() = doPulseTest( 76 | pulsePosition = 1f, 77 | "${CSI}38;2;97;175;239m━${CSI}39m", 78 | width = 1 79 | ) 80 | 81 | private fun doPulseTest(pulsePosition: Float, expected: String, width: Int = 10) { 82 | checkRender( 83 | ProgressBar(indeterminate = true, pulsePosition = pulsePosition), 84 | expected, 85 | width = width 86 | ) 87 | } 88 | 89 | private fun doPercentTest(completed: Long, expected: String, theme: Theme = Theme.PlainAscii) { 90 | checkRender( 91 | ProgressBar(completed = completed), 92 | expected, 93 | theme = theme, 94 | width = 5, 95 | trimMargin = false, 96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SpinnerTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.rendering.TextColors.red 4 | import com.github.ajalt.mordant.test.RenderingTest 5 | import io.kotest.matchers.shouldBe 6 | import kotlin.test.Test 7 | 8 | class SpinnerTest : RenderingTest() { 9 | @Test 10 | fun dots() = doTest(Spinner.Dots(), "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") 11 | 12 | @Test 13 | fun line() = doTest(Spinner.Lines(), "|", "/", "-", "\\") 14 | 15 | @Test 16 | fun custom() = doTest(Spinner("ab"), "a", "b", "a", "b") 17 | 18 | @Test 19 | fun duration() = doTest(Spinner("12", duration = 2), "1", "1", "2", "2", "1", "1", "2", "2") 20 | 21 | @Test 22 | fun lineStyle() = doTest(Spinner.Lines(red), red("|"), red("/"), red("-"), red("\\")) 23 | 24 | @Test 25 | fun initialTick() = doTest(Spinner.Lines(initial = 2), "-", "\\", "|", "/") 26 | 27 | private fun doTest( 28 | spinner: Spinner, 29 | vararg expected: String, 30 | ) { 31 | for (ex in expected) { 32 | checkRender(spinner, ex, trimMargin = false) 33 | val t = spinner.tick 34 | spinner.advanceTick() shouldBe (t + 1) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/UnorderedListTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.test.RenderingTest 4 | import kotlin.js.JsName 5 | import kotlin.test.Test 6 | 7 | class UnorderedListTest : RenderingTest() { 8 | @[Test JsName("vararg_string_constructor")] 9 | fun `vararg string constructor`() = checkRender( 10 | UnorderedList("one", "two", "three"), 11 | """ 12 | ░ • one 13 | ░ • two 14 | ░ • three 15 | """ 16 | ) 17 | 18 | @[Test JsName("vararg_widget_constructor")] 19 | fun `vararg widget constructor`() = checkRender( 20 | UnorderedList(Text("one"), Text("two"), Text("three")), 21 | """ 22 | ░ • one 23 | ░ • two 24 | ░ • three 25 | """ 26 | ) 27 | 28 | @[Test JsName("empty_list")] 29 | fun `empty list`() = checkRender( 30 | UnorderedList(emptyList()), 31 | "" 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/ViewportTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.widgets 2 | 3 | import com.github.ajalt.mordant.rendering.TextColors.red 4 | import com.github.ajalt.mordant.test.RenderingTest 5 | import io.kotest.data.blocking.forAll 6 | import io.kotest.data.row 7 | import kotlin.js.JsName 8 | import kotlin.test.Test 9 | 10 | class ViewportTest : RenderingTest(width = 20) { 11 | @Test 12 | fun crop() = forAll( 13 | row(null, null, "a ␊b c"), 14 | row(null, 1, "a "), 15 | row(1, 1, "a"), 16 | row(2, 1, "a "), 17 | row(1, null, "a␊b"), 18 | row(2, null, "a ␊b "), 19 | row(3, null, "a ␊b c"), 20 | row(2, 2, "a ␊b "), 21 | row(4, 3, "a ␊b c ␊ "), 22 | row(0, 0, ""), 23 | ) { w, h, ex -> 24 | doTest(w, h, 0, 0, ex, "a\nb c") 25 | } 26 | 27 | @Test 28 | fun scroll() = forAll( 29 | row(0, 0, "a ␊b c"), 30 | row(1, 0, " ␊ c "), 31 | row(2, 0, " ␊c "), 32 | row(3, 0, " ␊ "), 33 | row(4, 0, " ␊ "), 34 | row(0, 1, "b c␊ "), 35 | row(0, 2, " ␊ "), 36 | row(1, 1, " c ␊ "), 37 | row(9, 9, " ␊ "), 38 | row(-1, 0, " a ␊ b "), 39 | row(-2, 0, " a␊ b"), 40 | row(-3, 0, " ␊ "), 41 | row(0, -1, " ␊a "), 42 | row(0, -2, " ␊ "), 43 | row(-9, -9, " ␊ "), 44 | ) { x, y, ex -> 45 | doTest(null, null, x, y, ex, "a\nb c") 46 | } 47 | 48 | @[Test JsName("scrolling_splits_span")] 49 | fun `scrolling splits span`() { 50 | doTest(1, 1, 2, 0, red("Z"), "X${red("YZ")}") 51 | doTest(2, 1, 1, 0, "56", "4567") 52 | } 53 | 54 | private fun doTest(w: Int?, h: Int?, x: Int, y: Int, ex: String, txt: String) { 55 | checkRender(Viewport(Text(txt), w, h, x, y), ex, trimMargin = false) { 56 | it.replace('\n', '␊') 57 | } 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /mordant/src/commonTest/resources/multiplatform_system_test.txt: -------------------------------------------------------------------------------- 1 | pass 2 | -------------------------------------------------------------------------------- /mordant/src/iosMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.ios.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.TerminalInterface 4 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceNativeCopyPasted 5 | 6 | internal actual fun getStandardTerminalInterface(): TerminalInterface { 7 | return TerminalInterfaceNativeCopyPasted() 8 | } 9 | -------------------------------------------------------------------------------- /mordant/src/iosMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.ios.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface 2 | 3 | import com.github.ajalt.mordant.rendering.Size 4 | import kotlinx.cinterop.* 5 | import platform.posix.* 6 | 7 | // XXX: The source code for this file is identical between linux and the various apple targets, but 8 | // they have different bit widths for some fields, so the compileMetadata task fails if we don't use 9 | // separate files. Hopefully some day there will be solution that doesn't require copy-pasting. 10 | 11 | internal class TerminalInterfaceNativeCopyPasted : TerminalInterfaceNativePosix() { 12 | override val termiosConstants: TermiosConstants = TermiosConstants( 13 | VTIME = VTIME, 14 | VMIN = VMIN, 15 | INPCK = INPCK.convert(), 16 | ISTRIP = ISTRIP.convert(), 17 | INLCR = INLCR.convert(), 18 | IGNCR = IGNCR.convert(), 19 | ICRNL = ICRNL.convert(), 20 | IXON = IXON.convert(), 21 | OPOST = OPOST.convert(), 22 | CS8 = CS8.convert(), 23 | ISIG = ISIG.convert(), 24 | ICANON = ICANON.convert(), 25 | ECHO = ECHO.convert(), 26 | IEXTEN = IEXTEN.convert(), 27 | ) 28 | 29 | override fun readIntoBuffer(c: ByteVar): Long { 30 | return read(platform.posix.STDIN_FILENO, c.ptr, 1u).convert() 31 | } 32 | 33 | override fun getTerminalSize(): Size? = memScoped { 34 | val size = alloc() 35 | if (ioctl(STDIN_FILENO, TIOCGWINSZ.convert(), size) < 0) { 36 | null 37 | } else { 38 | Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) 39 | } 40 | } 41 | 42 | override fun getStdinTermios(): Termios = memScoped { 43 | val termios = alloc() 44 | if (tcgetattr(platform.posix.STDIN_FILENO, termios.ptr) != 0) { 45 | throw RuntimeException("Error reading terminal attributes") 46 | } 47 | return Termios( 48 | iflag = termios.c_iflag.convert(), 49 | oflag = termios.c_oflag.convert(), 50 | cflag = termios.c_cflag.convert(), 51 | lflag = termios.c_lflag.convert(), 52 | cc = ByteArray(NCCS) { termios.c_cc[it].convert() }, 53 | ) 54 | } 55 | 56 | override fun setStdinTermios(termios: Termios) = memScoped { 57 | val nativeTermios = alloc() 58 | // different platforms have different fields in termios, so we need to read the current 59 | // struct before we set the fields we care about. 60 | if (tcgetattr(platform.posix.STDIN_FILENO, nativeTermios.ptr) != 0) { 61 | throw RuntimeException("Error reading terminal attributes") 62 | } 63 | nativeTermios.c_iflag = termios.iflag.convert() 64 | nativeTermios.c_oflag = termios.oflag.convert() 65 | nativeTermios.c_cflag = termios.cflag.convert() 66 | nativeTermios.c_lflag = termios.lflag.convert() 67 | repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].convert() } 68 | if (tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) != 0) { 69 | throw RuntimeException("Error setting terminal attributes") 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.* 4 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceBrowser 5 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsCommon 6 | 7 | 8 | // Since `js()` and `external` work differently in wasm and js, we need to define the functions that 9 | // use them twice 10 | internal expect fun makeNodeTerminalInterface(): TerminalInterfaceJsCommon? 11 | internal expect fun browserPrintln(message: String) 12 | 13 | private class JsAtomicRef(override var value: T) : MppAtomicRef { 14 | override fun compareAndSet(expected: T, newValue: T): Boolean { 15 | if (value != expected) return false 16 | value = newValue 17 | return true 18 | } 19 | 20 | override fun getAndSet(newValue: T): T { 21 | val old = value 22 | value = newValue 23 | return old 24 | } 25 | } 26 | 27 | private class JsAtomicInt(initial: Int) : MppAtomicInt { 28 | private var backing = initial 29 | override fun getAndIncrement(): Int { 30 | return backing++ 31 | } 32 | 33 | override fun get(): Int { 34 | return backing 35 | } 36 | 37 | override fun set(value: Int) { 38 | backing = value 39 | } 40 | } 41 | 42 | internal actual fun MppAtomicInt(initial: Int): MppAtomicInt = JsAtomicInt(initial) 43 | internal actual fun MppAtomicRef(value: T): MppAtomicRef = JsAtomicRef(value) 44 | 45 | 46 | internal actual fun getStandardTerminalInterface(): TerminalInterface { 47 | return makeNodeTerminalInterface() ?: TerminalInterfaceBrowser 48 | } 49 | 50 | private val impls get() = STANDARD_TERM_INTERFACE as TerminalInterfaceJsCommon 51 | 52 | internal actual fun runningInIdeaJavaAgent(): Boolean = false 53 | 54 | internal actual fun getEnv(key: String): String? = impls.readEnvvar(key) 55 | internal actual fun printStderr(message: String, newline: Boolean) { 56 | impls.printStderr(message, newline) 57 | } 58 | 59 | internal actual fun exitProcessMpp(status: Int): Unit = impls.exitProcess(status) 60 | 61 | // hideInput is not currently implemented 62 | internal actual fun readLineOrNullMpp(hideInput: Boolean): String? = impls.readLineOrNull() 63 | 64 | 65 | internal actual fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor { 66 | return impls.makeTerminalCursor(terminal) 67 | } 68 | 69 | internal actual fun readFileIfExists(filename: String): String? = impls.readFileIfExists(filename) 70 | 71 | 72 | internal actual fun sendInterceptedPrintRequest( 73 | request: PrintRequest, 74 | terminalInterface: TerminalInterface, 75 | interceptors: List, 76 | ) { 77 | terminalInterface.completePrintRequest( 78 | interceptors.fold(request) { acc, it -> it.intercept(acc) } 79 | ) 80 | } 81 | 82 | internal actual fun testsHaveFileSystem(): Boolean = impls !is TerminalInterfaceBrowser 83 | -------------------------------------------------------------------------------- /mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface 2 | 3 | import com.github.ajalt.mordant.input.InputEvent 4 | import com.github.ajalt.mordant.input.MouseTracking 5 | import com.github.ajalt.mordant.internal.browserPrintln 6 | import com.github.ajalt.mordant.rendering.Size 7 | import com.github.ajalt.mordant.terminal.PrintTerminalCursor 8 | import com.github.ajalt.mordant.terminal.StandardTerminalInterface 9 | import com.github.ajalt.mordant.terminal.Terminal 10 | import com.github.ajalt.mordant.terminal.TerminalCursor 11 | import kotlin.time.TimeMark 12 | 13 | internal abstract class TerminalInterfaceJsCommon : StandardTerminalInterface() { 14 | abstract fun readEnvvar(key: String): String? 15 | abstract fun printStderr(message: String, newline: Boolean) 16 | abstract fun readLineOrNull(): String? 17 | abstract fun makeTerminalCursor(terminal: Terminal): TerminalCursor 18 | abstract fun exitProcess(status: Int) 19 | abstract fun readFileIfExists(filename: String): String? 20 | 21 | } 22 | 23 | internal abstract class TerminalInterfaceNode : TerminalInterfaceJsCommon() { 24 | final override fun readLineOrNull(): String? { 25 | return try { 26 | buildString { 27 | val buf = allocBuffer(1) 28 | do { 29 | val char = readByteWithBuf(buf) ?: break 30 | append(char) 31 | } while (char != "\n" && char != "${0.toChar()}") 32 | } 33 | } catch (e: Exception) { 34 | null 35 | } 36 | } 37 | 38 | abstract fun allocBuffer(size: Int): BufferT 39 | abstract fun bufferToString(buffer: BufferT): String 40 | abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int 41 | 42 | override fun readInputEvent(timeout: TimeMark, mouseTracking: MouseTracking): InputEvent? { 43 | return PosixEventParser { 44 | readByteWithBuf(allocBuffer(1))?.let { it[0].code } 45 | }.readInputEvent(timeout) 46 | } 47 | 48 | private fun readByteWithBuf(buf: BufferT): String? { 49 | val len = readSync(fd = 0, buffer = buf, offset = 0, len = 1) 50 | if (len == 0) return null 51 | // don't call kotlin's toString here due to KT-55817 52 | return bufferToString(buf) 53 | } 54 | } 55 | 56 | internal object TerminalInterfaceBrowser : TerminalInterfaceJsCommon() { 57 | override fun readEnvvar(key: String): String? = null 58 | override fun stdoutInteractive(): Boolean = false 59 | override fun stdinInteractive(): Boolean = false 60 | override fun getTerminalSize(): Size? = null 61 | override fun printStderr(message: String, newline: Boolean) = browserPrintln(message) 62 | override fun exitProcess(status: Int) {} 63 | 64 | // readlnOrNull will just throw an exception on browsers 65 | override fun readLineOrNull(): String? = readlnOrNull() 66 | override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { 67 | return BrowserTerminalCursor(terminal) 68 | } 69 | 70 | override fun readFileIfExists(filename: String): String? = null 71 | 72 | } 73 | 74 | // There are no shutdown hooks on browsers, so we don't need to do anything here 75 | private class BrowserTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) 76 | -------------------------------------------------------------------------------- /mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/JsCompat.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | 4 | // https://github.com/iliakan/detect-node 5 | internal val isNode: Boolean = js( 6 | "Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'" 7 | ) as Boolean 8 | 9 | /** Load module [mod], or throw an exception if not running on NodeJS */ 10 | internal fun nodeRequire(mod: String): dynamic { 11 | require(isNode) { "Module not available: $mod" } 12 | 13 | // This hack exists to silence webpack warnings when running on the browser. `require` is a 14 | // built-in function on Node, and doesn't exist on browsers. Webpack will statically look for 15 | // calls to `require` and rewrite them into its own module loading system. This means that when 16 | // we have `require("fs")` in our code, webpack will complain during compilation with two types 17 | // of warnings: 18 | // 19 | // 1. It will warn about the module not existing (since it's node-only), even if we never 20 | // execute that statement at runtime on the browser. 21 | // 2. It will complain with a different warning if the argument to `require` isn't static 22 | // (e.g. `fun myRequire(m:String) { require(m) }`). 23 | // 24 | // If we do run `require("fs")` on the browser, webpack will normally cause it to throw a 25 | // `METHOD_NOT_FOUND` error. If the user marks `fs` as "external" in their webpack 26 | // configuration, it will silence the first type of warning above, and the `require` call 27 | // will now return `undefined` instead of throwing an exception. 28 | // 29 | // So since we never call `require` at runtime on browsers anyway, we hide our `require` 30 | // calls from webpack by loading the method dynamically. This prevents any warnings, and 31 | // doesn't require users to add anything to their webpack config. 32 | 33 | val imported = try { 34 | js("module['' + 'require']")(mod) 35 | } catch (e: dynamic) { 36 | throw IllegalArgumentException("Module not available: $mod", e as? Throwable) 37 | } 38 | require( 39 | imported != null && js("typeof imported !== 'undefined'").unsafeCast() 40 | ) { "Module not available: $mod" } 41 | return imported 42 | } 43 | -------------------------------------------------------------------------------- /mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsCommon 4 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsNode 5 | 6 | private external val console: dynamic 7 | private external val Symbol: dynamic 8 | 9 | internal actual fun browserPrintln(message: String) { 10 | // No way to avoid the newline on browsers 11 | console.error(message) 12 | } 13 | 14 | internal actual fun makeNodeTerminalInterface(): TerminalInterfaceJsCommon? { 15 | return try { 16 | TerminalInterfaceJsNode(nodeRequire("fs")) 17 | } catch (e: Exception) { 18 | null 19 | } 20 | } 21 | 22 | internal actual fun codepointSequence(string: String): Sequence { 23 | val it = string.asDynamic()[Symbol.iterator]() 24 | return generateSequence { 25 | it.next()["value"]?.codePointAt(0) as? Int 26 | } 27 | } 28 | 29 | internal actual val CR_IMPLIES_LF: Boolean = false 30 | -------------------------------------------------------------------------------- /mordant/src/jsMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.js.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface 2 | 3 | import com.github.ajalt.mordant.input.MouseTracking 4 | import com.github.ajalt.mordant.rendering.Size 5 | import com.github.ajalt.mordant.terminal.PrintTerminalCursor 6 | import com.github.ajalt.mordant.terminal.Terminal 7 | import com.github.ajalt.mordant.terminal.TerminalCursor 8 | 9 | 10 | private external val process: dynamic 11 | private external val Buffer: dynamic 12 | 13 | internal class TerminalInterfaceJsNode(private val fs: dynamic) : TerminalInterfaceNode() { 14 | override fun readEnvvar(key: String): String? = process.env[key] as? String 15 | override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean 16 | override fun stdinInteractive(): Boolean = js("Boolean(process.stdin.isTTY)") as Boolean 17 | override fun exitProcess(status: Int) { 18 | process.exit(status) 19 | } 20 | 21 | override fun getTerminalSize(): Size? { 22 | // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY 23 | // is false 24 | if (process.stdout.getWindowSize == undefined) return null 25 | val s = process.stdout.getWindowSize() 26 | return Size(width = s[0] as Int, height = s[1] as Int) 27 | } 28 | 29 | override fun printStderr(message: String, newline: Boolean) { 30 | process.stderr.write(if (newline) message + "\n" else message) 31 | } 32 | 33 | override fun allocBuffer(size: Int): dynamic { 34 | return Buffer.alloc(size) 35 | } 36 | 37 | override fun bufferToString(buffer: dynamic): String { 38 | return js("buffer.toString()") as String 39 | } 40 | 41 | override fun readSync(fd: Int, buffer: dynamic, offset: Int, len: Int): Int { 42 | try { 43 | return fs.readSync(fd, buffer, offset, len, null) as Int 44 | } catch (e: dynamic) { 45 | if (e.code == "EAGAIN") return 0 46 | throw e 47 | } 48 | } 49 | 50 | override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { 51 | return NodeTerminalCursor(terminal) 52 | } 53 | 54 | override fun readFileIfExists(filename: String): String? { 55 | return fs.readFileSync(filename, "utf-8") as? String 56 | } 57 | 58 | override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable { 59 | if (!stdinInteractive()) { 60 | throw RuntimeException("Cannot enter raw mode on a non-interactive terminal") 61 | } 62 | process.stdin.setRawMode(true) 63 | return AutoCloseable { 64 | process.stdin.setRawMode(false) 65 | Unit 66 | } 67 | } 68 | } 69 | 70 | private class NodeTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) { 71 | private var shutdownHook: (() -> Unit)? = null 72 | 73 | override fun show() { 74 | shutdownHook?.let { process.removeListener("exit", it) } 75 | super.show() 76 | } 77 | 78 | override fun hide(showOnExit: Boolean) { 79 | if (showOnExit && shutdownHook == null) { 80 | shutdownHook = { show() } 81 | process.on("exit", shutdownHook) 82 | } 83 | super.hide(showOnExit) 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface 2 | 3 | /** 4 | * A base class for terminal interfaces for JVM POSIX systems that uses `System.in` for input. 5 | */ 6 | abstract class TerminalInterfaceJvmPosix : TerminalInterfacePosix() { 7 | override fun readRawByte(): Int? { 8 | return System.`in`.read().takeUnless { it <= 0 } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/animation/progress/ThreadAnimatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.animation.progress 2 | 3 | import com.github.ajalt.mordant.animation.textAnimation 4 | import com.github.ajalt.mordant.internal.CSI 5 | import com.github.ajalt.mordant.terminal.Terminal 6 | import com.github.ajalt.mordant.terminal.TerminalRecorder 7 | import com.github.ajalt.mordant.widgets.progress.completed 8 | import com.github.ajalt.mordant.widgets.progress.progressBarLayout 9 | import io.kotest.matchers.shouldBe 10 | import io.kotest.matchers.string.shouldContain 11 | import kotlin.test.Test 12 | 13 | private const val HIDE_CURSOR = "$CSI?25l" 14 | private const val SHOW_CURSOR = "$CSI?25h" 15 | 16 | class ThreadAnimatorTest { 17 | private val vt = TerminalRecorder(width = 56) 18 | private val t = Terminal(terminalInterface = vt) 19 | 20 | @Test 21 | fun `unit animator`() { 22 | var i = 1 23 | val a = t.textAnimation { "${i++}" }.animateOnThread(fps = 10000) { i > 2 } 24 | a.runBlocking() 25 | vt.output() shouldBe "${HIDE_CURSOR}1\r2\r3" 26 | vt.clearOutput() 27 | a.stop() 28 | vt.output() shouldBe "\n$SHOW_CURSOR" 29 | vt.clearOutput() 30 | } 31 | 32 | @Test 33 | fun `multi progress animator`() { 34 | val layout = progressBarLayout { completed(fps = 100) } 35 | val animation = MultiProgressBarAnimation(t).animateOnThread() 36 | val task1 = animation.addTask(layout, total = 10) 37 | val task2 = animation.addTask(layout, total = 10) 38 | task1.advance(10) 39 | task2.advance(10) 40 | animation.runBlocking() 41 | vt.output().shouldContain(" 10/10\n 10/10") 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /mordant/src/jvmTest/kotlin/com/github/ajalt/mordant/terminal/StderrTerminalTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal 2 | 3 | import com.github.ajalt.mordant.test.RenderingTest 4 | import io.kotest.matchers.shouldBe 5 | import org.junit.Rule 6 | import org.junit.contrib.java.lang.system.SystemErrRule 7 | import org.junit.contrib.java.lang.system.SystemOutRule 8 | import kotlin.test.Test 9 | 10 | class StderrTerminalTest : RenderingTest() { 11 | @get:Rule 12 | val stdout: SystemOutRule = SystemOutRule().enableLog().muteForSuccessfulTests() 13 | 14 | @get:Rule 15 | val stderr: SystemErrRule = SystemErrRule().enableLog().muteForSuccessfulTests() 16 | 17 | @Test 18 | fun stderrTerminal() { 19 | val terminal = Terminal() 20 | 21 | terminal.print("foo", stderr = true) 22 | terminal.print("bar") 23 | 24 | stderr.logWithNormalizedLineSeparator shouldBe "foo" 25 | stdout.logWithNormalizedLineSeparator shouldBe "bar" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | internal actual fun testsHaveFileSystem(): Boolean = true 4 | -------------------------------------------------------------------------------- /mordant/src/macosMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.macos.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | internal actual fun testsHaveFileSystem(): Boolean = true 4 | -------------------------------------------------------------------------------- /mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.TerminalInterface 4 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceNativeWindows 5 | 6 | internal actual fun ttySetEcho(echo: Boolean) = TerminalInterfaceNativeWindows.ttySetEcho(echo) 7 | internal actual fun testsHaveFileSystem(): Boolean = true 8 | internal actual fun getStandardTerminalInterface(): TerminalInterface = 9 | TerminalInterfaceNativeWindows 10 | -------------------------------------------------------------------------------- /mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfacePosix 4 | import platform.posix.ECHO 5 | 6 | // https://www.gnu.org/software/libc/manual/html_node/getpass.html 7 | internal actual fun ttySetEcho(echo: Boolean) { 8 | val handlerPosix = STANDARD_TERM_INTERFACE as TerminalInterfacePosix 9 | val termios = handlerPosix.getStdinTermios() 10 | handlerPosix.setStdinTermios( 11 | termios.copy( 12 | lflag = when { 13 | echo -> termios.lflag or ECHO.toUInt() 14 | else -> termios.lflag and ECHO.inv().toUInt() 15 | } 16 | ) 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface 2 | 3 | import kotlinx.cinterop.ByteVar 4 | import kotlinx.cinterop.alloc 5 | import kotlinx.cinterop.memScoped 6 | import kotlinx.cinterop.value 7 | 8 | internal abstract class TerminalInterfaceNativePosix : TerminalInterfacePosix() { 9 | override fun isatty(fd: Int): Boolean { 10 | return platform.posix.isatty(fd) != 0 11 | } 12 | 13 | override fun readRawByte(): Int? = memScoped { 14 | val c = alloc() 15 | val read = readIntoBuffer(c) 16 | if (read < 0) throw RuntimeException("Error reading from stdin") 17 | if (read > 0) return c.value.toInt() 18 | return null 19 | } 20 | 21 | // `read` has different byte widths on linux and apple 22 | protected abstract fun readIntoBuffer(c: ByteVar): Long 23 | } 24 | -------------------------------------------------------------------------------- /mordant/src/posixSharedMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.posixshared.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.TerminalInterface 4 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceNativeShared 5 | 6 | internal actual fun getStandardTerminalInterface(): TerminalInterface { 7 | return TerminalInterfaceNativeShared() 8 | } 9 | -------------------------------------------------------------------------------- /mordant/src/posixSharedMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.shared.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.terminal.terminalinterface 2 | 3 | import com.github.ajalt.mordant.rendering.Size 4 | import kotlinx.cinterop.* 5 | import platform.posix.* 6 | 7 | // XXX: The source code for this file is identical between linux and the various apple targets, but 8 | // they have different bit widths for some fields, so the compileMetadata task fails if we don't use 9 | // separate files. Hopefully some day there will be solution that doesn't require copy-pasting. 10 | 11 | internal class TerminalInterfaceNativeShared : TerminalInterfaceNativePosix() { 12 | override val termiosConstants: TermiosConstants = TermiosConstants( 13 | VTIME = VTIME, 14 | VMIN = VMIN, 15 | INPCK = INPCK.convert(), 16 | ISTRIP = ISTRIP.convert(), 17 | INLCR = INLCR.convert(), 18 | IGNCR = IGNCR.convert(), 19 | ICRNL = ICRNL.convert(), 20 | IXON = IXON.convert(), 21 | OPOST = OPOST.convert(), 22 | CS8 = CS8.convert(), 23 | ISIG = ISIG.convert(), 24 | ICANON = ICANON.convert(), 25 | ECHO = ECHO.convert(), 26 | IEXTEN = IEXTEN.convert(), 27 | ) 28 | 29 | override fun readIntoBuffer(c: ByteVar): Long { 30 | return read(platform.posix.STDIN_FILENO, c.ptr, 1u).convert() 31 | } 32 | 33 | override fun getTerminalSize(): Size? = memScoped { 34 | val size = alloc() 35 | if (ioctl(STDIN_FILENO, TIOCGWINSZ.convert(), size) < 0) { 36 | null 37 | } else { 38 | Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) 39 | } 40 | } 41 | 42 | override fun getStdinTermios(): Termios = memScoped { 43 | val termios = alloc() 44 | if (tcgetattr(platform.posix.STDIN_FILENO, termios.ptr) != 0) { 45 | throw RuntimeException("Error reading terminal attributes") 46 | } 47 | return Termios( 48 | iflag = termios.c_iflag.convert(), 49 | oflag = termios.c_oflag.convert(), 50 | cflag = termios.c_cflag.convert(), 51 | lflag = termios.c_lflag.convert(), 52 | cc = ByteArray(NCCS) { termios.c_cc[it].convert() }, 53 | ) 54 | } 55 | 56 | override fun setStdinTermios(termios: Termios) = memScoped { 57 | val nativeTermios = alloc() 58 | // different platforms have different fields in termios, so we need to read the current 59 | // struct before we set the fields we care about. 60 | if (tcgetattr(platform.posix.STDIN_FILENO, nativeTermios.ptr) != 0) { 61 | throw RuntimeException("Error reading terminal attributes") 62 | } 63 | nativeTermios.c_iflag = termios.iflag.convert() 64 | nativeTermios.c_oflag = termios.oflag.convert() 65 | nativeTermios.c_cflag = termios.cflag.convert() 66 | nativeTermios.c_lflag = termios.lflag.convert() 67 | repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].convert() } 68 | if (tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) != 0) { 69 | throw RuntimeException("Error setting terminal attributes") 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.internal 2 | 3 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceJsCommon 4 | import com.github.ajalt.mordant.terminal.terminalinterface.TerminalInterfaceWasm 5 | 6 | internal actual fun browserPrintln(message: String): Unit = js("console.error(message)") 7 | 8 | internal actual fun makeNodeTerminalInterface(): TerminalInterfaceJsCommon? { 9 | return if (runningOnNode()) TerminalInterfaceWasm() else null 10 | } 11 | 12 | private external interface CodePointString { 13 | fun codePointAt(index: Int): Int 14 | } 15 | 16 | private external interface StringIteration { 17 | val value: CodePointString? 18 | } 19 | 20 | private external interface StringIterator { 21 | fun next(): StringIteration 22 | } 23 | 24 | private fun stringIterator(@Suppress("UNUSED_PARAMETER") s: String): StringIterator = 25 | js("s[Symbol.iterator]()") 26 | 27 | internal actual fun codepointSequence(string: String): Sequence { 28 | val it = stringIterator(string) 29 | return generateSequence { it.next().value?.codePointAt(0) } 30 | } 31 | 32 | // See jsMain for the details of node detection 33 | private fun runningOnNode(): Boolean = 34 | js("Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'") 35 | 36 | 37 | // For some reason, \r seems to be treated as \r\n on wasm 38 | internal actual val CR_IMPLIES_LF: Boolean = true 39 | -------------------------------------------------------------------------------- /prepare_docs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # The website is built using MkDocs with the Material theme. 4 | # https://squidfunk.github.io/mkdocs-material/ 5 | # Mkdocs requires Python to run. 6 | # Install the packages: `pip install mkdocs-material` 7 | # Build the api docs: `./gradlew dokkaHtmlMultiModule` 8 | # Then run this script to prepare the docs for the website. 9 | # Finally, run `mkdocs serve` to preview the site locally or `mkdocs build` to build the site. 10 | 11 | set -ex 12 | 13 | # Copy the changelog into the site, omitting the unreleased section 14 | cat CHANGELOG.md \ 15 | | grep -v '^## Unreleased' \ 16 | | sed '/^## /,$!d' \ 17 | > docs/changelog.md 18 | 19 | # Add the jinja frontmatter to the index 20 | cat > docs/index.md <<- EOM 21 | --- 22 | hide: 23 | - toc # Hide table of contents 24 | --- 25 | 26 | EOM 27 | 28 | # Copy the README into the index, omitting the docs link, license and fixing hrefs 29 | cat README.md \ 30 | | sed '/## License/Q' \ 31 | | sed -z 's/## Documentation[a-zA-z .\n()/:]*//g' \ 32 | | sed 's!docs/img!img!g' \ 33 | >> docs/index.md 34 | -------------------------------------------------------------------------------- /runsample: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Run one of the samples. 3 | # The first argument must be the name of the sample task (e.g. echo). 4 | # Any remaining arguments are forwarded to the sample's argv. 5 | 6 | task=$1 7 | shift 1 8 | 9 | if [ -z "${task}" ] || [ ! -d "samples/${task}" ] 10 | then 11 | echo "Unknown sample: '${task}'" 12 | exit 1 13 | fi 14 | 15 | ./gradlew --quiet ":samples:${task}:installDist" && "./samples/${task}/build/install/${task}/bin/${task}" "$@" 16 | -------------------------------------------------------------------------------- /runsample.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%"=="" @echo off 2 | :: Run one of the samples. 3 | :: The first argument must be the name of the sample task (e.g. echo). 4 | :: Any remaining arguments are forwarded to the sample's argv. 5 | 6 | if "%OS%"=="Windows_NT" setlocal EnableDelayedExpansion 7 | 8 | set TASK=%~1 9 | 10 | set SAMPLE=false 11 | if defined TASK if not "!TASK: =!"=="" if exist "samples\%TASK%\*" set SAMPLE=true 12 | 13 | if "%SAMPLE%"=="false" ( 14 | echo Unknown sample: '%TASK%' 15 | exit /b 1 16 | ) 17 | 18 | set ARGS=%* 19 | set ARGS=!ARGS:*%1=! 20 | if "!ARGS:~0,1!"==" " set ARGS=!ARGS:~1! 21 | 22 | call gradlew --quiet ":samples:%TASK%:installDist" && call "samples\%TASK%\build\install\%TASK%\bin\%TASK%" %ARGS% 23 | 24 | if "%OS%"=="Windows_NT" endlocal 25 | -------------------------------------------------------------------------------- /samples/detection/README.md: -------------------------------------------------------------------------------- 1 | # Detection Sample 2 | 3 | This sample shows the detected terminal, e.g. to collect information for issue reports. 4 | -------------------------------------------------------------------------------- /samples/detection/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-sample-conventions") 3 | } 4 | -------------------------------------------------------------------------------- /samples/detection/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.samples 2 | 3 | import com.github.ajalt.mordant.rendering.Whitespace.NORMAL 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | import com.github.ajalt.mordant.widgets.Panel 6 | import com.github.ajalt.mordant.widgets.Text 7 | import com.github.ajalt.mordant.widgets.withPadding 8 | 9 | fun main() { 10 | val terminal = Terminal() 11 | val theme = terminal.theme 12 | terminal.println( 13 | Panel( 14 | Text(terminal.terminalInfo.toString(), whitespace = NORMAL).withPadding(1), 15 | Text(theme.info("Detected Terminal Info")) 16 | ) 17 | ) 18 | terminal.println( 19 | Panel( 20 | Text( 21 | "${theme.success("success")}, " + 22 | "${theme.danger("danger")}, " + 23 | "${theme.warning("warning")}, " + 24 | "${theme.info("info")}, " + 25 | theme.muted("muted") 26 | ).withPadding(1), 27 | Text(theme.info("Theme colors")) 28 | ) 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /samples/drawing/README.md: -------------------------------------------------------------------------------- 1 | # Drawing 2 | 3 | This sample shows how to read mouse input events to draw on the screen. -------------------------------------------------------------------------------- /samples/drawing/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-sample-conventions") 3 | } 4 | 5 | kotlin { 6 | sourceSets { 7 | commonMain.dependencies { 8 | implementation(project(":mordant-coroutines")) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.samples 2 | 3 | import com.github.ajalt.colormath.Color 4 | import com.github.ajalt.colormath.model.HSL 5 | import com.github.ajalt.colormath.model.Oklab 6 | import com.github.ajalt.colormath.model.RGB 7 | import com.github.ajalt.colormath.transform.interpolator 8 | import com.github.ajalt.mordant.animation.coroutines.animateInCoroutine 9 | import com.github.ajalt.mordant.animation.textAnimation 10 | import com.github.ajalt.mordant.input.KeyboardEvent 11 | import com.github.ajalt.mordant.input.MouseEvent 12 | import com.github.ajalt.mordant.input.MouseTracking 13 | import com.github.ajalt.mordant.input.coroutines.receiveEventsFlow 14 | import com.github.ajalt.mordant.input.isCtrlC 15 | import com.github.ajalt.mordant.rendering.AnsiLevel 16 | import com.github.ajalt.mordant.rendering.TextColors 17 | import com.github.ajalt.mordant.terminal.Terminal 18 | import kotlinx.coroutines.coroutineScope 19 | import kotlinx.coroutines.flow.filter 20 | import kotlinx.coroutines.flow.filterIsInstance 21 | import kotlinx.coroutines.flow.takeWhile 22 | import kotlinx.coroutines.launch 23 | 24 | suspend fun main() = coroutineScope { 25 | val terminal = Terminal(ansiLevel = AnsiLevel.TRUECOLOR, interactive = true) 26 | var hue = 0 27 | val canvas = List(terminal.size.height - 1) { 28 | MutableList(terminal.size.width) { RGB("#000") } 29 | } 30 | val animation = terminal.textAnimation { 31 | buildString { 32 | for ((y, row) in canvas.withIndex()) { 33 | for ((x, color) in row.withIndex()) { 34 | append(TextColors.color(color).bg(" ")) 35 | canvas[y][x] = Oklab.interpolator { 36 | stop(color) 37 | stop(RGB("#000")) 38 | }.interpolate(0.025) 39 | } 40 | append("\n") 41 | } 42 | } 43 | }.animateInCoroutine() 44 | 45 | launch { animation.execute() } 46 | 47 | terminal.receiveEventsFlow(MouseTracking.Button) 48 | .takeWhile { it !is KeyboardEvent || !it.isCtrlC } 49 | .filterIsInstance() 50 | .filter { it.left } 51 | .collect { event -> 52 | canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) 53 | hue += 2 54 | } 55 | 56 | animation.clear() 57 | } 58 | -------------------------------------------------------------------------------- /samples/hexviewer/README.md: -------------------------------------------------------------------------------- 1 | # Hexviewer Sample 2 | 3 | This sample is a hex viewer like `xxd` which uses mordant for color and layout. 4 | 5 | ``` 6 | $ hexviewer picture.png 7 | ``` 8 | 9 | ![](example.png) 10 | -------------------------------------------------------------------------------- /samples/hexviewer/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-sample-conventions") 3 | } 4 | 5 | kotlin { 6 | sourceSets { 7 | commonMain.dependencies { 8 | implementation("com.squareup.okio:okio:3.9.0") 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /samples/hexviewer/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajalt/mordant/f98b0f055f45f32e4a6595ea328e3d9cb2a5fd2d/samples/hexviewer/example.png -------------------------------------------------------------------------------- /samples/hexviewer/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.samples 2 | 3 | import com.github.ajalt.mordant.rendering.BorderType 4 | import com.github.ajalt.mordant.rendering.TextStyle 5 | import com.github.ajalt.mordant.table.Borders 6 | import com.github.ajalt.mordant.table.table 7 | import com.github.ajalt.mordant.terminal.Terminal 8 | import com.github.ajalt.mordant.terminal.danger 9 | import okio.FileSystem 10 | import okio.Path.Companion.toPath 11 | import okio.SYSTEM 12 | 13 | 14 | fun main(args: Array) { 15 | val terminal = Terminal() 16 | if (args.size != 1) { 17 | terminal.danger("Usage: hexviewer ") 18 | return 19 | } 20 | val path = args[0].toPath() 21 | if (!FileSystem.SYSTEM.exists(path)) { 22 | terminal.danger("File not found: $path") 23 | return 24 | } 25 | 26 | // The characters to show for each byte value 27 | val display = "·␁␂␃␄␅␆␇␈␉␊␋␌␍␎␏␐␑␒␓␔␕␖␗␘␙␚␛␜␝␞␟" + 28 | " !\"#$%&'()*+,-./0123456789:;<=>?@" + 29 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + 30 | "[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~␡" 31 | 32 | // The text style for each byte value 33 | val styles = List(256) { 34 | when (it) { 35 | 0 -> TextStyle(dim = true) 36 | in 1..31, 127 -> terminal.theme.danger 37 | in 32..126 -> TextStyle() 38 | else -> terminal.theme.warning 39 | } 40 | } 41 | 42 | // 4 chars per octet, -20 for borders + address 43 | val w = (terminal.size.width - 20) / 4 44 | // round down to nearest multiple of 8 45 | val octetsPerRow = (w - w % 8).coerceAtLeast(1) 46 | 47 | val bytes = FileSystem.SYSTEM.read(path) { readByteArray() } 48 | 49 | val table = table { 50 | cellBorders = Borders.LEFT_RIGHT 51 | tableBorders = Borders.ALL 52 | borderType = BorderType.ROUNDED 53 | column(0) { style = terminal.theme.info } 54 | 55 | body { 56 | for (addr in bytes.indices step octetsPerRow) { 57 | val hex = StringBuilder() 58 | val ascii = StringBuilder() 59 | for (i in addr..<(addr + octetsPerRow).coerceAtMost(bytes.size)) { 60 | val byte = bytes[i].toInt() and 0xff 61 | if (i > addr) { 62 | if (i % 8 == 0) hex.append("┆") else hex.append(" ") 63 | } 64 | val s = styles[byte] 65 | hex.append(s(byte.toString(16).padStart(2, '0'))) 66 | ascii.append(s(display.getOrElse(byte) { '·' }.toString())) 67 | } 68 | row( 69 | "0x" + addr.toString(16).padStart(8, '0'), 70 | hex.toString(), 71 | ascii.toString() 72 | ) 73 | } 74 | } 75 | 76 | } 77 | terminal.println(table) 78 | } 79 | -------------------------------------------------------------------------------- /samples/markdown/README.md: -------------------------------------------------------------------------------- 1 | # Markdown Sample 2 | 3 | This sample is a command-line application that takes the path to a markdown file as an argument and renders that file to the terminal. -------------------------------------------------------------------------------- /samples/markdown/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-jvm-sample-conventions") 3 | } 4 | 5 | kotlin { 6 | sourceSets { 7 | jvmMain.dependencies { 8 | implementation(project(":mordant-markdown")) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /samples/markdown/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.samples 2 | 3 | import com.github.ajalt.mordant.markdown.Markdown 4 | import com.github.ajalt.mordant.terminal.Terminal 5 | import java.io.File 6 | 7 | fun main(args: Array) { 8 | val terminal = Terminal() 9 | val path = args.singleOrNull() ?: error("must specify a markdown file") 10 | val markdown = File(path).readText() 11 | val widget = Markdown(markdown) 12 | terminal.println(widget) 13 | } 14 | -------------------------------------------------------------------------------- /samples/progress/README.md: -------------------------------------------------------------------------------- 1 | # Progress Sample 2 | 3 | This sample shows a simulated wget-style download progress. -------------------------------------------------------------------------------- /samples/progress/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-jvm-sample-conventions") 3 | } 4 | 5 | kotlin { 6 | sourceSets { 7 | jvmMain.dependencies { 8 | implementation(project(":mordant-coroutines")) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /samples/select/README.md: -------------------------------------------------------------------------------- 1 | # Select List Sample 2 | 3 | This sample shows how to use the interactive select list widgets. -------------------------------------------------------------------------------- /samples/select/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-sample-conventions") 3 | } 4 | -------------------------------------------------------------------------------- /samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.samples 2 | 3 | import com.github.ajalt.mordant.input.interactiveMultiSelectList 4 | import com.github.ajalt.mordant.input.interactiveSelectList 5 | import com.github.ajalt.mordant.terminal.Terminal 6 | import com.github.ajalt.mordant.terminal.danger 7 | import com.github.ajalt.mordant.terminal.success 8 | 9 | 10 | fun main() { 11 | val terminal = Terminal() 12 | val theme = terminal.theme 13 | val size = terminal.interactiveSelectList( 14 | listOf("Small", "Medium", "Large", "X-Large"), 15 | title = "Select a Pizza Size", 16 | ) 17 | if (size == null) { 18 | terminal.danger("Aborted pizza order") 19 | return 20 | } 21 | val toppings = terminal.interactiveMultiSelectList { 22 | addEntry("Pepperoni", selected = true) 23 | addEntry("Sausage", selected = true) 24 | addEntry("Mushrooms") 25 | addEntry("Olives") 26 | addEntry("Pineapple") 27 | addEntry("Anchovies") 28 | title("Select Toppings") 29 | limit(4) 30 | filterable(true) 31 | } 32 | 33 | if (toppings == null) { 34 | terminal.danger("Aborted pizza order") 35 | return 36 | } 37 | val toppingString = if (toppings.isEmpty()) "no toppings" else toppings.joinToString() 38 | terminal.success("You ordered a ${theme.info(size)} pizza with ${theme.info(toppingString)}") 39 | } 40 | -------------------------------------------------------------------------------- /samples/table/README.md: -------------------------------------------------------------------------------- 1 | # Table Sample 2 | 3 | This sample shows many of the features of the table DSL. It supports multiplatform. 4 | -------------------------------------------------------------------------------- /samples/table/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-sample-conventions") 3 | } 4 | -------------------------------------------------------------------------------- /samples/table/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.samples 2 | 3 | import com.github.ajalt.mordant.rendering.BorderType.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR 4 | import com.github.ajalt.mordant.rendering.TextAlign.LEFT 5 | import com.github.ajalt.mordant.rendering.TextAlign.RIGHT 6 | import com.github.ajalt.mordant.rendering.TextColors.* 7 | import com.github.ajalt.mordant.rendering.TextStyle 8 | import com.github.ajalt.mordant.rendering.TextStyles.dim 9 | import com.github.ajalt.mordant.table.Borders.* 10 | import com.github.ajalt.mordant.table.table 11 | import com.github.ajalt.mordant.terminal.Terminal 12 | 13 | 14 | fun main() { 15 | val terminal = Terminal() 16 | 17 | val table = table { 18 | tableBorders = NONE 19 | borderType = SQUARE_DOUBLE_SECTION_SEPARATOR 20 | align = RIGHT 21 | column(0) { 22 | align = LEFT 23 | style = magenta 24 | } 25 | column(3) { 26 | style = magenta 27 | } 28 | header { 29 | style = magenta 30 | row("", "Projected Cost", "Actual Cost", "Difference") 31 | } 32 | body { 33 | cellBorders = TOP_BOTTOM 34 | column(0) { 35 | style = TextStyle(bold = true) 36 | cellBorders = ALL 37 | } 38 | column(3) { 39 | style = TextStyle(bold = true) 40 | cellBorders = ALL 41 | } 42 | rowStyles(blue, brightBlue) 43 | 44 | row("Food", "$400", "$200", "$200") 45 | row("Data", "$100", "$150", "$-50") 46 | row("Rent", "$800", "$800", "$0") 47 | row("Candles", "$0", "$3,600", "$-3,600") 48 | row("Utility", "$154", "$150", "$-5") 49 | } 50 | footer { 51 | row { 52 | cell("Subtotal") 53 | cell("$-3,455") { 54 | columnSpan = 3 55 | } 56 | } 57 | } 58 | captionBottom(dim("Budget courtesy @dril")) 59 | } 60 | 61 | terminal.println(table) 62 | } 63 | -------------------------------------------------------------------------------- /samples/tour/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("mordant-mpp-sample-conventions") 3 | } 4 | 5 | kotlin { 6 | sourceSets { 7 | commonMain.dependencies { 8 | implementation(project(":mordant-markdown")) 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "mordant" 2 | 3 | include( 4 | "mordant", 5 | "mordant-omnibus", 6 | "mordant-jvm-jna", 7 | "mordant-jvm-ffm", 8 | "mordant-jvm-graal-ffi", 9 | "mordant-coroutines", 10 | "mordant-markdown", 11 | "samples:detection", 12 | "samples:drawing", 13 | "samples:hexviewer", 14 | "samples:markdown", 15 | "samples:progress", 16 | "samples:select", 17 | "samples:table", 18 | "samples:tour", 19 | "test:graalvm", 20 | "test:proguard", 21 | ) 22 | 23 | @Suppress("UnstableApiUsage") 24 | dependencyResolutionManagement { 25 | repositories { 26 | mavenCentral() 27 | } 28 | } 29 | 30 | pluginManagement { 31 | repositories { 32 | gradlePluginPortal() 33 | mavenCentral() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/graalvm/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | alias(libs.plugins.graalvm.nativeimage) 4 | } 5 | 6 | tasks.test { 7 | useJUnitPlatform() 8 | } 9 | 10 | dependencies { 11 | implementation(project(":mordant-omnibus")) 12 | implementation(project(":mordant-markdown")) 13 | testImplementation(kotlin("test")) 14 | } 15 | 16 | graalvmNative { 17 | binaries { 18 | named("test") { 19 | quickBuild.set(true) 20 | buildArgs( 21 | // https://github.com/oracle/graal/issues/6957 22 | "--initialize-at-build-time=kotlin.annotation.AnnotationTarget,kotlin.annotation.AnnotationRetention", 23 | ) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/graalvm/src/test/kotlin/GraalSmokeTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.graalvm 2 | 3 | import com.github.ajalt.mordant.animation.progress.animateOnThread 4 | import com.github.ajalt.mordant.animation.progress.execute 5 | import com.github.ajalt.mordant.markdown.Markdown 6 | import com.github.ajalt.mordant.rendering.AnsiLevel 7 | import com.github.ajalt.mordant.rendering.TextStyles.bold 8 | import com.github.ajalt.mordant.terminal.Terminal 9 | import com.github.ajalt.mordant.terminal.TerminalRecorder 10 | import com.github.ajalt.mordant.widgets.progress.progressBar 11 | import com.github.ajalt.mordant.widgets.progress.progressBarLayout 12 | import org.junit.jupiter.api.Assertions.assertEquals 13 | import org.junit.jupiter.api.Test 14 | import org.junit.jupiter.api.condition.EnabledInNativeImage 15 | import java.util.concurrent.TimeUnit 16 | import kotlin.test.fail 17 | 18 | /** 19 | * Smoke tests for the GraalVM platform. 20 | * 21 | * They just make sure nothing crashes; the actual output is verified in the normal test suite. 22 | */ 23 | @EnabledInNativeImage 24 | class GraalSmokeTest { 25 | @Test 26 | fun `terminal detection test`() { 27 | val name = Terminal().terminalInterface::class.simpleName 28 | Terminal() 29 | val assertion = name!!.startsWith("TerminalInterfaceNativeImage") 30 | if (!assertion) { 31 | fail("Incorrect terminal interface: $name") 32 | } 33 | } 34 | 35 | @Test 36 | fun `progress animation test`() { 37 | val t = Terminal(interactive = true, ansiLevel = AnsiLevel.TRUECOLOR) 38 | val animation = progressBarLayout { progressBar() }.animateOnThread(t, total = 1) 39 | val future = animation.execute() 40 | Thread.sleep(100) 41 | animation.update { completed = 1 } 42 | future.get(1000, TimeUnit.MILLISECONDS) 43 | } 44 | 45 | @Test 46 | fun `markdown test`() { 47 | val vt = TerminalRecorder() 48 | val t = Terminal(terminalInterface = vt) 49 | t.print(Markdown("- Some **bold** text")) 50 | assertEquals(" • Some ${bold("bold")} text", vt.output()) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/proguard/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("jvm") 3 | } 4 | 5 | repositories { 6 | google() 7 | mavenCentral() 8 | } 9 | 10 | val r8: Configuration by configurations.creating 11 | 12 | dependencies { 13 | implementation(project(":mordant-omnibus")) 14 | implementation(project(":mordant-markdown")) 15 | implementation(project(":mordant-coroutines")) 16 | r8(libs.r8) 17 | } 18 | 19 | 20 | val fatJar by tasks.register("fatJar") { 21 | archiveClassifier = "fat" 22 | duplicatesStrategy = DuplicatesStrategy.INCLUDE 23 | 24 | from(sourceSets.main.get().output) 25 | 26 | dependsOn(configurations.runtimeClasspath) 27 | from({ 28 | configurations.runtimeClasspath.get().filter { it.name.endsWith("jar") }.map { zipTree(it) } 29 | }) 30 | 31 | manifest { 32 | attributes["Main-Class"] = "com.github.ajalt.mordant.main.R8SmokeTestKt" 33 | attributes["Implementation-Version"] = archiveVersion 34 | } 35 | 36 | exclude("**/*.kotlin_metadata") 37 | exclude("**/*.kotlin_module") 38 | exclude("**/*.kotlin_builtins") 39 | exclude("**/module-info.class") 40 | } 41 | 42 | 43 | val r8JarProvider by tasks.register("r8Jar") { 44 | dependsOn(fatJar) 45 | 46 | val r8File = layout.buildDirectory.file("libs/main-r8.jar") 47 | val rulesFile = project.file("src/main/rules.pro") 48 | 49 | val fatJarFile = fatJar.archiveFile 50 | 51 | inputs.files(fatJarFile, rulesFile) 52 | outputs.file(r8File) 53 | 54 | classpath(r8) 55 | mainClass.set("com.android.tools.r8.R8") 56 | args = listOf( 57 | "--release", 58 | "--classfile", 59 | "--output", r8File.get().asFile.toString(), 60 | "--pg-conf", rulesFile.path, 61 | "--lib", System.getProperty("java.home").toString(), 62 | fatJarFile.get().toString(), 63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /test/proguard/src/main/kotlin/R8SmokeTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.ajalt.mordant.main 2 | 3 | import com.github.ajalt.mordant.animation.coroutines.animateInCoroutine 4 | import com.github.ajalt.mordant.markdown.Markdown 5 | import com.github.ajalt.mordant.rendering.AnsiLevel 6 | import com.github.ajalt.mordant.terminal.Terminal 7 | import com.github.ajalt.mordant.widgets.progress.progressBar 8 | import com.github.ajalt.mordant.widgets.progress.progressBarLayout 9 | import kotlinx.coroutines.coroutineScope 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.launch 12 | 13 | suspend fun main(args: Array) = coroutineScope { 14 | // make sure that the terminal detection doesn't crash. 15 | Terminal() 16 | 17 | // make sure animations and markdown don't crash. 18 | val t = Terminal(interactive = true, ansiLevel = AnsiLevel.TRUECOLOR) 19 | val animation = progressBarLayout { progressBar() }.animateInCoroutine(t, total = 1) 20 | launch { animation.execute() } 21 | t.print(Markdown("- Your args: **${args.asList()}**")) 22 | delay(100) 23 | animation.update { completed = 1 } 24 | } 25 | -------------------------------------------------------------------------------- /test/proguard/src/main/rules.pro: -------------------------------------------------------------------------------- 1 | -keep class com.github.ajalt.mordant.main.R8SmokeTestKt { 2 | public static void main(java.lang.String[]); 3 | } 4 | --------------------------------------------------------------------------------