From 4f1204ad6112c9c43bf922ac26daccd7cb7d5124 Mon Sep 17 00:00:00 2001 From: David Herman Date: Thu, 20 Oct 2022 21:47:57 -0700 Subject: [PATCH] WIP: OSC hyperlinks Bug #84 --- examples/text/src/main/kotlin/main.kt | 7 ++ .../kotter/foundation/input/InputSupport.kt | 2 +- .../kotter/foundation/text/LinkSupport.kt | 20 ++++++ .../runtime/internal/TerminalCommand.kt | 5 +- .../kotter/runtime/internal/ansi/Ansi.kt | 69 ++++++++++++++++++- .../internal/ansi/commands/AnsiCommand.kt | 5 +- .../internal/ansi/commands/LinkCommand.kt | 15 ++++ 7 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/LinkSupport.kt create mode 100644 kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/LinkCommand.kt diff --git a/examples/text/src/main/kotlin/main.kt b/examples/text/src/main/kotlin/main.kt index 9a8f56ad..39cba68b 100644 --- a/examples/text/src/main/kotlin/main.kt +++ b/examples/text/src/main/kotlin/main.kt @@ -122,5 +122,12 @@ fun main() = session { // This is more to make sure our virtual terminal handles emojis well than anything else, really textLine("Emoji test: \uD83D\uDE00\uD83D\uDC4B\uD83D\uDE80") } + + // Test links + p { + text("Thank you for taking the time to ") + link("https://github.com/varabyte/kotter", "learn Kotter") + textLine("!") + } }.run() } \ No newline at end of file diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/foundation/input/InputSupport.kt b/kotter/src/main/kotlin/com/varabyte/kotter/foundation/input/InputSupport.kt index e8e2e909..78e02fcd 100644 --- a/kotter/src/main/kotlin/com/varabyte/kotter/foundation/input/InputSupport.kt +++ b/kotter/src/main/kotlin/com/varabyte/kotter/foundation/input/InputSupport.kt @@ -45,7 +45,7 @@ private fun ConcurrentScopedData.prepareKeyFlow(terminal: Terminal) { if (c == Ansi.CtrlChars.ESC) escSeq.clear() escSeq.append(c) - val code = Ansi.EscSeq.toCode(escSeq) + val code = Ansi.EscSeq.toCsiCode(escSeq) if (code != null) { escSeq.clear() when (code) { diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/LinkSupport.kt b/kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/LinkSupport.kt new file mode 100644 index 00000000..c17b8fe1 --- /dev/null +++ b/kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/LinkSupport.kt @@ -0,0 +1,20 @@ +package com.varabyte.kotter.foundation.text + +import com.varabyte.kotter.runtime.internal.ansi.commands.LinkCommand +import com.varabyte.kotter.runtime.render.RenderScope +import java.net.URI + +/** + * Render text backed by a link, so it can be clicked to navigate to some URI. + * + * Note: It is not guaranteed that this feature is supported in every terminal, so you may not want to use it if it's + * really important for the user to be able to click on the URL. If the feature isn't supported, [displayText] will + * be rendered as plain text. + */ +fun RenderScope.link(uri: URI, displayText: String = uri.toString()) { + applyCommand(LinkCommand(uri, displayText, emptyMap())) +} + +fun RenderScope.link(uri: String, displayText: String = uri) { + link(URI.create(uri), displayText) +} diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/TerminalCommand.kt b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/TerminalCommand.kt index fc0b742f..e4819ab3 100644 --- a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/TerminalCommand.kt +++ b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/TerminalCommand.kt @@ -10,4 +10,7 @@ internal open class TerminalCommand(val text: String) { open fun applyTo(state: SectionState, renderer: Renderer<*>) { renderer.appendCommand(this) } -} \ No newline at end of file +} + +@Suppress("FunctionName") // Function designed to look like a constructor +internal fun CompositeTerminalCommand(vararg commands: TerminalCommand) = TerminalCommand(commands.joinToString("") { it.text }) \ No newline at end of file diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/Ansi.kt b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/Ansi.kt index 82b43c1d..f20a4399 100644 --- a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/Ansi.kt +++ b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/Ansi.kt @@ -2,6 +2,7 @@ package com.varabyte.kotter.runtime.internal.ansi import com.varabyte.kotter.runtime.internal.text.TextPtr import com.varabyte.kotter.runtime.internal.text.readInt +import java.net.URI /** * A collection of common ANSI codes and other related constants which power the features of @@ -25,17 +26,25 @@ object Ansi { // https://en.wikipedia.org/wiki/ANSI_escape_code#Fe_Escape_sequences object EscSeq { const val CSI = '[' + const val OSC = ']' // Hack alert: For a reason I don't understand yet, Windows uses 'O' and not '[' for a handful of its escape // sequence characters. 'O' normally represents "function shift" but I'm not finding great documentation about // it. For now, it seems to work OK if we just treat 'O' like '[' on Windows sometimes. private const val CSI_ALT = 'O' - fun toCode(sequence: CharSequence): Csi.Code? { + fun toCsiCode(sequence: CharSequence): Csi.Code? { if (sequence.length < 3) return null if (sequence[0] != CtrlChars.ESC || (sequence[1] !in listOf(CSI, CSI_ALT))) return null val parts = Csi.Code.parts(TextPtr(sequence, 2)) ?: return null return Csi.Code(parts) } + + fun toOscCode(sequence: CharSequence): Osc.Code? { + if (sequence.length < 3) return null + if (sequence[0] != CtrlChars.ESC || (sequence[1] != OSC)) return null + val parts = Osc.Code.parts(TextPtr(sequence, 2)) ?: return null + return Osc.Code(parts) + } } // https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences @@ -93,7 +102,11 @@ object Ansi { } data class Parts(val numericCode: Int?, val optionalCodes: List?, val identifier: Char) { - override fun toString() = "${if (numericCode != null) "$numericCode" else ""}${if (optionalCodes != null) ";${optionalCodes.joinToString(";")}" else ""}$identifier" + override fun toString() = buildString { + if (numericCode != null) append(numericCode.toString()) + if (optionalCodes != null) append(optionalCodes.joinToString(";")) + append(identifier) + } } fun toFullEscapeCode(): String = "${CtrlChars.ESC}${EscSeq.CSI}${parts}" @@ -208,4 +221,56 @@ object Ansi { } } } + + // https://en.wikipedia.org/wiki/ANSI_escape_code#OSC_(Operating_System_Command)_sequences + object Osc { + /** The code for this OSC command, e.g. the "8;(params);(uri)" part of "ESC]8;(params);(uri)ESC\" */ + class Code(val parts: Parts) { + constructor(value: String): this( + parts(value) ?: throw IllegalArgumentException("Invalid OSC code: $value") + ) + + companion object { + fun parts(text: CharSequence): Parts? { + return parts(TextPtr(text)) + } + + fun parts(textPtr: TextPtr): Parts? { + val numericCode = textPtr.readInt() ?: return null + val params = buildList { + while (textPtr.currChar == ';') { + textPtr.increment() + val startIndex = textPtr.charIndex + textPtr.incrementWhile { it != ';' } + add(textPtr.text.substring(startIndex, textPtr.charIndex)) + } + } + return Parts(numericCode, params) + } + } + + data class Parts(val numericCode: Int, val params: List) { + override fun toString() = buildString { + append(numericCode); append(';') + append(params.joinToString(";")) + } + } + + fun toFullEscapeCode(): String = "${CtrlChars.ESC}${EscSeq.OSC}${parts}${CtrlChars.ESC}\\" + override fun equals(other: Any?): Boolean { + return other is Code && other.parts == parts + } + + override fun hashCode(): Int = parts.hashCode() + + override fun toString() = toFullEscapeCode() + } + + object Codes { + fun openLink(uri: URI, params: Map = emptyMap()) = + Code("8;${params.map { (k, v) -> "$k=$v" }.joinToString(":")};${uri}") + + fun closeLink() = Code("8;;") + } + } } \ No newline at end of file diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/AnsiCommand.kt b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/AnsiCommand.kt index 192dc98b..3ecdb57f 100644 --- a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/AnsiCommand.kt +++ b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/AnsiCommand.kt @@ -1,7 +1,10 @@ package com.varabyte.kotter.runtime.internal.ansi.commands import com.varabyte.kotter.runtime.internal.TerminalCommand +import com.varabyte.kotter.runtime.internal.ansi.Ansi import com.varabyte.kotter.runtime.internal.ansi.Ansi.Csi +import com.varabyte.kotter.runtime.internal.ansi.Ansi.Osc internal open class AnsiCommand(ansiCode: String) : TerminalCommand(ansiCode) -internal open class AnsiCsiCommand(csiCode: Csi.Code) : AnsiCommand(csiCode.toFullEscapeCode()) \ No newline at end of file +internal open class AnsiCsiCommand(csiCode: Csi.Code) : AnsiCommand(csiCode.toFullEscapeCode()) +internal open class AnsiOscCommand(oscCode: Osc.Code) : AnsiCommand(oscCode.toFullEscapeCode()) \ No newline at end of file diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/LinkCommand.kt b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/LinkCommand.kt new file mode 100644 index 00000000..1a22fe2c --- /dev/null +++ b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/internal/ansi/commands/LinkCommand.kt @@ -0,0 +1,15 @@ +package com.varabyte.kotter.runtime.internal.ansi.commands + +import com.varabyte.kotter.runtime.internal.CompositeTerminalCommand +import com.varabyte.kotter.runtime.internal.ansi.Ansi +import java.net.URI + +/** + * A console command which displays a clickable link in the terminal. + */ +@Suppress("FunctionName") // Function designed to look like a constructor +internal fun LinkCommand(uri: URI, displayText: String, params: Map) = CompositeTerminalCommand( + AnsiOscCommand(Ansi.Osc.Codes.openLink(uri, params)), + TextCommand(displayText), + AnsiOscCommand(Ansi.Osc.Codes.closeLink()) +) \ No newline at end of file