Skip to content

Commit

Permalink
WIP: OSC hyperlinks
Browse files Browse the repository at this point in the history
Bug #84
  • Loading branch information
bitspittle committed Oct 21, 2022
1 parent 48e8df4 commit 0ae8db0
Show file tree
Hide file tree
Showing 9 changed files with 301 additions and 17 deletions.
7 changes: 7 additions & 0 deletions examples/text/src/main/kotlin/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.varabyte.kotter.foundation.text

import com.varabyte.kotter.runtime.internal.ansi.commands.CloseLinkCommand
import com.varabyte.kotter.runtime.internal.ansi.commands.OpenLinkCommand
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) {
link(uri) { text(displayText) }
}

fun RenderScope.link(uri: String, displayText: String) {
link(URI(uri), displayText)
}

/**
* Render a block of commands backed by a link, so anywhere 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, the block will be
* rendered as plain text.
*
* If you try to open a new link block within an existing link block, this method will throw an exception.
*/
// Bug(#91): Currently closed off from public use for now, since it seems needlessly powerful. However, we have a bug
// where people can request that we make this available in a future version.
// If we ever make this public, then uncomment out the alternate convenience version below
internal fun RenderScope.link(uri: URI, block: RenderScope.() -> Unit) {
val lastOpen = section.renderer.commands.indexOfLast { command -> command is OpenLinkCommand }
val lastClose = section.renderer.commands.indexOfLast { command -> command === CloseLinkCommand }

check(lastOpen < 0 || lastOpen < lastClose) {
"Attempted to open a link block within another link block"
}

applyCommand(OpenLinkCommand(uri))
block()
applyCommand(CloseLinkCommand)
}

// Bug(#91): Uncomment me if the above method ever becomes public
//fun RenderScope.link(uri: String, block: RenderScope.() -> Unit) {
// link(URI(uri), block)
//}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ internal open class TerminalCommand(val text: String) {
open fun applyTo(state: SectionState, renderer: Renderer<*>) {
renderer.appendCommand(this)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.varabyte.kotter.runtime.internal.ansi

import com.varabyte.kotter.runtime.internal.text.TextPtr
import com.varabyte.kotter.runtime.internal.text.readInt
import com.varabyte.kotter.runtime.internal.text.readUntil
import com.varabyte.kotter.runtime.internal.text.substring
import java.net.URI

/**
* A collection of common ANSI codes and other related constants which power the features of
Expand All @@ -25,6 +28,7 @@ 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.
Expand Down Expand Up @@ -211,4 +215,82 @@ object Ansi {
}
}
}

// https://en.wikipedia.org/wiki/ANSI_escape_code#OSC_(Operating_System_Command)_sequences
object Osc {
/** The single numeric identifier for an OSC code, e.g. 8 in ESC]8;;" */
class Identifier(val code: Int) {
companion object {
private val identifierObjects = mutableMapOf<Int, Identifier>()
fun fromCode(code: Int): Identifier? = identifierObjects[code]
fun fromCode(code: Code): Identifier? = fromCode(code.parts.numericCode)
}

init {
identifierObjects[code] = this
}

override fun toString() = code.toString()
}

object Identifiers {
val ANCHOR = Identifier(8)
}

/** The code for this OSC command, e.g. the "8;(params);(uri)ESC\" 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 oscText = TextPtr(textPtr.readUntil { textPtr.substring(2) == "${CtrlChars.ESC}\\" })
val params = buildList {
while (oscText.currChar == ';') {
oscText.increment()
add(oscText.readUntil { currChar == ';' })
}
}

// Consume the trailing part of the OSC code, if present
if (textPtr.substring(2) == "${CtrlChars.ESC}\\") {
textPtr.increment()
// We leave the textPtr on the last part of the code, e.g. the \, as it's expected the caller
// will increment one more time on their end to move to the next part
}

return Parts(numericCode, params)
}
}

data class Parts(val numericCode: Int, val params: List<String>) {
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<String, String> = emptyMap()) =
Code("${Identifiers.ANCHOR};${params.map { (k, v) -> "$k=$v" }.joinToString(":")};${uri}")

val CLOSE_LINK = Code("${Identifiers.ANCHOR};;")
}
}
}
Original file line number Diff line number Diff line change
@@ -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())
internal open class AnsiCsiCommand(csiCode: Csi.Code) : AnsiCommand(csiCode.toFullEscapeCode())
internal open class AnsiOscCommand(oscCode: Osc.Code) : AnsiCommand(oscCode.toFullEscapeCode())
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.varabyte.kotter.runtime.internal.ansi.commands

import com.varabyte.kotter.runtime.internal.ansi.Ansi
import java.net.URI

/**
* A command for starting a block that allows for clickable links in the terminal.
*
* See also: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#the-escape-sequence
*/
// Note: Link commands support the reading in parameters, although currently it doesn't seem like there are any
// parameters really worth worrying about in practice. According to the docs, only "id" is valid right now and I don't
// think Kotter applications need it. So for simplicity, we don't allow them for now.
internal class OpenLinkCommand(uri: URI) : AnsiOscCommand(Ansi.Osc.Codes.openLink(uri, emptyMap()))

/**
* A command for closing a block opened by [OpenLinkCommand].
*/
internal val CloseLinkCommand = AnsiOscCommand(Ansi.Osc.Codes.CLOSE_LINK)
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,22 @@ fun TextPtr.readInt(): Int? {
intValue += digit
}
return intValue
}

/**
* Read the current string up until we reach a point where the condition is no longer true.
*
* The character that the text pointer will be on when the condition fails will NOT be included, so for example:
*
* ```
* TextPtr("Hello;World").readUntil { currChar == ';' }
* ```
*
* will return "Hello", not "Hello;"
*/
fun TextPtr.readUntil(condition: TextPtr.() -> Boolean) = buildString {
while (!condition() && currChar != Char.MIN_VALUE) {
append(currChar)
increment()
}
}
Loading

0 comments on commit 0ae8db0

Please sign in to comment.