Skip to content

Commit

Permalink
Add OSC support, add OSC 8 (anchor) support to the public api, add a …
Browse files Browse the repository at this point in the history
…simple implementation in VirtualTerminal, and use in 'input' example. Fixes varabyte#84.
  • Loading branch information
40ft committed Jun 21, 2022
1 parent 8055ec4 commit 007dfdf
Show file tree
Hide file tree
Showing 7 changed files with 152 additions and 10 deletions.
12 changes: 11 additions & 1 deletion examples/input/src/main/kotlin/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@ import com.varabyte.kotter.foundation.liveVarOf
import com.varabyte.kotter.foundation.runUntilSignal
import com.varabyte.kotter.foundation.text.*
import com.varabyte.kotter.runtime.MainRenderScope
import java.net.URI

fun main() = session {
// Scenario #1 - trivial but common case. A section exists to request a single input from the user
run {
var wantsToLearn by liveVarOf(false)
section {
text("Would you like to learn "); cyan { text("Kotter") }; textLine("? (Y/n)")
text("Would you like to ");
cyan {
underline()
anchor(URI("https://github.com/varabyte/kotter"))
text("learn ")
bold()
text("Kotter")
// anchor(URI("https://github.com/varabyte/kotter"), "Kotter") << a simpler usage
};
textLine("? (Y/n)")
text("> "); input(Completions("yes", "no"), initialText = "y")

if (wantsToLearn) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.varabyte.kotter.foundation.text

import com.varabyte.kotter.runtime.internal.ansi.commands.AnchorCommand
import com.varabyte.kotter.runtime.render.RenderScope
import java.net.URI

/**
* Open an 'anchor' or hyperlink of [uri] in the current scope. Leaving the scope will close the anchor.
*/
fun RenderScope.anchor(uri: URI) {
applyCommand(AnchorCommand(uri))
}

/**
* Open and close an 'anchor' or hyperlink of [uri] with the given [displayText]
*/
fun RenderScope.anchor(uri: URI, displayText: CharSequence) {
applyCommand(AnchorCommand(uri))
text(displayText)
applyCommand(AnchorCommand())
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class SectionState internal constructor(internal val parent: SectionState? = nul
var bolded: TerminalCommand? = parentStyles?.bolded
var struckThrough: TerminalCommand? = parentStyles?.struckThrough
var inverted: TerminalCommand? = parentStyles?.inverted
var anchor: TerminalCommand? = parentStyles?.anchor
}

/** Styles which are actively applied, and any text rendered right now would use them. */
Expand Down Expand Up @@ -74,5 +75,9 @@ class SectionState internal constructor(internal val parent: SectionState? = nul
applied.inverted = deferred.inverted
renderer.appendCommand(applied.inverted ?: CLEAR_INVERT_COMMAND)
}
if (deferred.anchor?.text !== applied.anchor?.text) {
applied.anchor = deferred.anchor
renderer.appendCommand(applied.anchor ?: AnchorCommand())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,31 @@ object Ansi {
const val ENTER = '\u000D'
const val ESC = '\u001B'
const val DELETE = '\u007F'
const val BEL = '\u0007'
}

// 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
return Osc.Code(TextPtr(sequence, 2))
}
}

// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
Expand Down Expand Up @@ -206,4 +215,49 @@ object Ansi {
}
}
}

object Osc {

object ANCHOR : Code('8')

open class Code(val id: Char, val parts: Parts? = null) {
constructor(value: TextPtr) : this(
value.currChar, parts(value)
)

companion object {
fun parts(text: CharSequence): Parts {
return parts(TextPtr(text))
}

fun parts(textPtr: TextPtr): Parts {
textPtr.increment()
check(textPtr.currChar == ';')
val buff = StringBuilder()
textPtr.incrementUntil {
if (it in listOf(CtrlChars.BEL, CtrlChars.ESC)) return@incrementUntil true
buff.append(it)
false
}
if (textPtr.currChar == CtrlChars.ESC) {
textPtr.increment()
check(textPtr.currChar == '\\') { "Improperly terminated OSC code" }
}
return Parts(buff.toString().split(";"))
}
}

data class Parts(val params: List<String>) {
override fun toString() = params.joinToString(";")
}

fun toFullEscapeCode(): String = "${CtrlChars.ESC}${EscSeq.OSC}$id;${parts}${CtrlChars.ESC}\\"

override fun equals(other: Any?): Boolean {
return other is Code && other.parts == parts
}

override fun hashCode(): Int = parts.hashCode()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
package com.varabyte.kotter.runtime.internal.ansi.commands

import com.varabyte.kotter.runtime.SectionState
import com.varabyte.kotter.runtime.internal.TerminalCommand
import com.varabyte.kotter.runtime.internal.ansi.Ansi.Csi
import com.varabyte.kotter.runtime.internal.ansi.Ansi.Osc
import com.varabyte.kotter.runtime.render.Renderer
import java.net.URI

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())

internal open class AnchorCommand(uri: URI? = null, params: Map<String, String>? = null) :
AnsiOscCommand(
Osc.Code(
Osc.ANCHOR.id,
Osc.Code.Parts(
listOf(
params?.map { "${it.key}=${it.value}" }?.joinToString(":") ?: "",
uri?.toString() ?: "",
)
)
)
) {

override fun applyTo(state: SectionState, renderer: Renderer<*>) {
state.deferred.anchor = this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import java.awt.event.*
import java.awt.event.WindowEvent.WINDOW_CLOSING
import java.awt.geom.Point2D
import java.net.MalformedURLException
import java.net.URI
import java.net.URL
import java.nio.file.Path
import java.util.concurrent.CountDownLatch
Expand Down Expand Up @@ -251,6 +252,9 @@ private fun Document.getText() = getText(0, length)
class SwingTerminalPane(font: Font, fgColor: Color, bgColor: Color, maxNumLines: Int) : JTextPane() {
private val sgrCodeConverter: SgrCodeConverter

private val uris = mutableMapOf<Pair<Int, Int>, URI>()
private var currUri: Pair<Int, URI>? = null

init {
isEditable = false
foreground = fgColor
Expand All @@ -274,16 +278,18 @@ class SwingTerminalPane(font: Font, fgColor: Color, bgColor: Color, maxNumLines:
resetMouseListeners()
}

private fun getWordUnderPt(pt: Point2D): String {
private fun getWordUnderPt(pt: Point2D): Pair<String, URI?> {
val offset = this.viewToModel2D(pt)
val textPtr = TextPtr(styledDocument.getText(), offset)

val uriUnderPt = uris.toList().find { (range, _) -> textPtr.charIndex in range.first..range.second }?.second

textPtr.incrementUntil { it.isWhitespace() }
val end = textPtr.charIndex

textPtr.decrementUntil { it.isWhitespace() }
val start = textPtr.charIndex
return textPtr.substring(end - start)
return textPtr.substring(end - start) to uriUnderPt
}

private fun resetMouseListeners() {
Expand All @@ -294,9 +300,9 @@ class SwingTerminalPane(font: Font, fgColor: Color, bgColor: Color, maxNumLines:

addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseMoved(e: MouseEvent) {
val wordUnderCursor = getWordUnderPt(e.point)
val (wordUnderCursor, uriUnderCursor) = getWordUnderPt(e.point)
cursor = try {
URL(wordUnderCursor)
uriUnderCursor ?: URL(wordUnderCursor)
Cursor.getPredefinedCursor(HAND_CURSOR)
} catch (ignored: MalformedURLException) {
Cursor.getDefaultCursor()
Expand All @@ -306,9 +312,9 @@ class SwingTerminalPane(font: Font, fgColor: Color, bgColor: Color, maxNumLines:

addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
val wordUnderCursor = getWordUnderPt(e.point)
val (wordUnderCursor, uriUnderCursor) = getWordUnderPt(e.point)
try {
Desktop.getDesktop().browse(URL(wordUnderCursor).toURI())
Desktop.getDesktop().browse(uriUnderCursor ?: URL(wordUnderCursor).toURI())
}
catch (ignored: MalformedURLException) {}
}
Expand All @@ -319,6 +325,7 @@ class SwingTerminalPane(font: Font, fgColor: Color, bgColor: Color, maxNumLines:
if (!textPtr.increment()) return false
return when (textPtr.currChar) {
Ansi.EscSeq.CSI -> processCsiCode(textPtr, doc, attrs)
Ansi.EscSeq.OSC -> processOscCode(textPtr, doc, attrs)
else -> false
}
}
Expand Down Expand Up @@ -376,6 +383,27 @@ class SwingTerminalPane(font: Font, fgColor: Color, bgColor: Color, maxNumLines:
}
}

private fun processOscCode(textPtr: TextPtr, doc: Document, attrs: MutableAttributeSet): Boolean {
if (!textPtr.increment()) return false

val oscCode = Ansi.Osc.Code(textPtr)

return when (oscCode.id) {
Ansi.Osc.ANCHOR.id -> {
val ignored = oscCode.parts?.params?.get(0)
val uri = oscCode.parts?.params?.get(1)
if (currUri != null) {
uris[currUri!!.first to doc.length] = currUri!!.second
}
if (!uri.isNullOrEmpty()) {
currUri = doc.length to URI(uri)
}
true
}
else -> return false
}
}

fun processAnsiText(text: String) {
require(SwingUtilities.isEventDispatchThread())
if (text.isEmpty()) return
Expand Down

0 comments on commit 007dfdf

Please sign in to comment.