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 700aa23 commit 7865044
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 2 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,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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ internal open class TerminalCommand(val text: String) {
open fun applyTo(state: SectionState, renderer: Renderer<*>) {
renderer.appendCommand(this)
}
}
}

/**
* A sequence of terminal commands wrapped into one, useful for multi-part commands.
*/
@Suppress("FunctionName") // Function designed to look like a constructor
internal fun CompositeTerminalCommand(vararg commands: TerminalCommand) = TerminalCommand(commands.joinToString("") { it.text })
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +26,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 @@ -212,4 +214,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<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("8;${params.map { (k, v) -> "$k=$v" }.joinToString(":")};${uri}")

val CLOSE_LINK = Code("8;;")
}
}
}
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,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<String, String>) = CompositeTerminalCommand(
AnsiOscCommand(Ansi.Osc.Codes.openLink(uri, params)),
TextCommand(displayText),
AnsiOscCommand(Ansi.Osc.Codes.CLOSE_LINK)
)

0 comments on commit 7865044

Please sign in to comment.