From 007dfdfa6fe370879ff708d48ecce6369c96aae8 Mon Sep 17 00:00:00 2001 From: Craig Date: Wed, 22 Jun 2022 09:22:36 +1000 Subject: [PATCH] Add OSC support, add OSC 8 (anchor) support to the public api, add a simple implementation in VirtualTerminal, and use in 'input' example. Fixes #84. --- examples/input/src/main/kotlin/main.kt | 12 +++- .../kotter/foundation/input/InputSupport.kt | 2 +- .../kotter/foundation/text/OscSupport.kt | 21 +++++++ .../varabyte/kotter/runtime/SectionState.kt | 5 ++ .../kotter/runtime/internal/ansi/Ansi.kt | 56 ++++++++++++++++++- .../internal/ansi/commands/AnsiCommand.kt | 26 ++++++++- .../kotter/terminal/VirtualTerminal.kt | 40 +++++++++++-- 7 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/OscSupport.kt diff --git a/examples/input/src/main/kotlin/main.kt b/examples/input/src/main/kotlin/main.kt index 847a4413..6b4ba390 100644 --- a/examples/input/src/main/kotlin/main.kt +++ b/examples/input/src/main/kotlin/main.kt @@ -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) { 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 88d719d8..1d7db956 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/OscSupport.kt b/kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/OscSupport.kt new file mode 100644 index 00000000..eb1e1209 --- /dev/null +++ b/kotter/src/main/kotlin/com/varabyte/kotter/foundation/text/OscSupport.kt @@ -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()) +} diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/SectionState.kt b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/SectionState.kt index d32b3c8f..92c08665 100644 --- a/kotter/src/main/kotlin/com/varabyte/kotter/runtime/SectionState.kt +++ b/kotter/src/main/kotlin/com/varabyte/kotter/runtime/SectionState.kt @@ -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. */ @@ -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()) + } } } \ 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 20f1b020..9234cc07 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 @@ -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 @@ -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) { + 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() + } + } } \ 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..7abdc367 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,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()) \ 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()) + +internal open class AnchorCommand(uri: URI? = null, params: Map? = 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 + } +} \ No newline at end of file diff --git a/kotter/src/main/kotlin/com/varabyte/kotter/terminal/VirtualTerminal.kt b/kotter/src/main/kotlin/com/varabyte/kotter/terminal/VirtualTerminal.kt index 293ac65d..a6290e32 100644 --- a/kotter/src/main/kotlin/com/varabyte/kotter/terminal/VirtualTerminal.kt +++ b/kotter/src/main/kotlin/com/varabyte/kotter/terminal/VirtualTerminal.kt @@ -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 @@ -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, URI>() + private var currUri: Pair? = null + init { isEditable = false foreground = fgColor @@ -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 { 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() { @@ -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() @@ -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) {} } @@ -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 } } @@ -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