Skip to content

Commit

Permalink
Add new escapes to 'string/tty', rename to 'tty', deprecate ANSI na…
Browse files Browse the repository at this point in the history
…mespace (#728)

I've been sitting on these changes for a good while now, so let's upstream 'em.

Among others adds:
- ANSI256 colours
- all sorts of combinators for manipulating the terminal: change title,
move cursor, clear line, clear screen, hyperlink, ...

Be warned, this might break some users who rely on escapes and/or the 'string/tty' module directly.
  • Loading branch information
jiribenes authored Dec 10, 2024
1 parent 45a1006 commit 92a6e0f
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 69 deletions.
66 changes: 0 additions & 66 deletions libraries/common/string/tty.effekt

This file was deleted.

6 changes: 3 additions & 3 deletions libraries/common/test.effekt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import string/tty
import tty
import process
import bench

Expand Down Expand Up @@ -52,8 +52,8 @@ def assertEqual[A](obtained: A, expected: A, msg: String) { equals: (A, A) => Bo
do assert(equals(obtained, expected), msg)

def assertEqual[A](obtained: A, expected: A) { equals: (A, A) => Bool } { show: A => String }: Unit / { Assertion, Formatted } =
do assert(equals(obtained, expected), Formatted::tryEmit(ANSI::RESET) ++ "Expected: ".dim ++ show(expected).green ++ "\n Obtained: ".dim ++ show(obtained).red)
// NOTE: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
do assert(equals(obtained, expected), Formatted::tryEmit(Escape::RESET) ++ "Expected: ".dim ++ show(expected).green ++ "\n Obtained: ".dim ++ show(obtained).red)
// NOTE: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Here's an accidental capture! Can we prevent this somehow nicely?


Expand Down
192 changes: 192 additions & 0 deletions libraries/common/tty.effekt
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
module tty

/// Represents a TTY color.
/// - ANSI for the standard 16 terminal colors
/// - ANSI256 for standard 256 terminal colors
/// - NoColor for explicit "this doesn't have a set color"
type Color {
ANSI(code: Int)
ANSI256(code: Int)
NoColor()
}

/// Common terminal colors and their constructors
namespace Color {
// Standard ANSI colors
val BLACK = ANSI(0)
val RED = ANSI(1)
val GREEN = ANSI(2)
val YELLOW = ANSI(3)
val BLUE = ANSI(4)
val MAGENTA = ANSI(5)
val CYAN = ANSI(6)
val WHITE = ANSI(7)

// Bright ANSI colors
val BRIGHT_BLACK = ANSI(8)
val BRIGHT_RED = ANSI(9)
val BRIGHT_GREEN = ANSI(10)
val BRIGHT_YELLOW = ANSI(11)
val BRIGHT_BLUE = ANSI(12)
val BRIGHT_MAGENTA = ANSI(13)
val BRIGHT_CYAN = ANSI(14)
val BRIGHT_WHITE = ANSI(15)

/// Create an ANSI color from a code
def ansi(code: Int): Color =
if (code < 16) ANSI(code) else ANSI256(code)
}

/// Converts a color to its escape code
def toEscapeSeq(color: Color): String = color match {
case ANSI(code) =>
if (code < 8) (code + 30).show else (code + 82).show
case ANSI256(code) => "38;5;" ++ code.show
case NoColor() => ""
}

/// Common escapes
namespace Escape {
val CSI = "\u001b["
val OSC = "\u001b]"

val RESET = CSI ++ "0m"
val BOLD = CSI ++ "1m"
val FAINT = CSI ++ "2m"
val ITALIC = CSI ++ "3m"
val UNDERLINE = CSI ++ "4m"
val BLINK = CSI ++ "5m"
val REVERSE = CSI ++ "7m"
val CROSSOUT = CSI ++ "9m"
val OVERLINE = CSI ++ "53m"
}

def escape(code: String): String = Escape::CSI ++ code
def escapeOSC(code: String): String = Escape::OSC ++ code

// Cursor positioning
def cursorUp(x: Int): String = escape(x.show ++ "A")
def cursorDown(x: Int): String = escape(x.show ++ "B")
def cursorForward(x: Int): String = escape(x.show ++ "C")
def cursorBack(x: Int): String = escape(x.show ++ "D")
def cursorNextLine(x: Int): String = escape(x.show ++ "E")
def cursorPreviousLine(x: Int): String = escape(x.show ++ "F")
def cursorHorizontal(x: Int): String = escape(x.show ++ "G")
def cursorPosition(x: Int, y: Int): String = escape(y.show ++ ";" ++ x.show ++ "H")

// Screen manipulation
def eraseDisplay(x: Int): String = escape(x.show ++ "J")
def eraseLine(x: Int): String = escape(x.show ++ "K")
def scrollUp(x: Int): String = escape(x.show ++ "S")
def scrollDown(x: Int): String = escape(x.show ++ "T")
def saveCursorPosition(): String = escape("s")
def restoreCursorPosition(): String = escape("u")
def changeScrollingRegion(x: Int, y: Int): String = escape(x.show ++ ";" ++ y.show ++ "r")
def insertLine(x: Int): String = escape(x.show ++ "L")
def deleteLine(x: Int): String = escape(x.show ++ "M")

// Explicit values for eraseLine
def eraseLineRight(): String = escape("0K")
def eraseLineLeft(): String = escape("1K")
def eraseEntireLine(): String = escape("2K")

// Mouse
def enableMousePress(): String = escape("?9h")
def disableMousePress(): String = escape("?9l")
def enableMouse(): String = escape("?1000h")
def disableMouse(): String = escape("?1000l")
def enableMouseHighlight(): String = escape("?1001h")
def disableMouseHighlight(): String = escape("?1001l")
def enableMouseCellMotion(): String = escape("?1002h")
def disableMouseCellMotion(): String = escape("?1002l")
def enableMouseAllMotion(): String = escape("?1003h")
def disableMouseAllMotion(): String = escape("?1003l")
def enableMouseExtendedMode(): String = escape("?1006h")
def disableMouseExtendedMode(): String = escape("?1006l")
def enableMousePixelsMode(): String = escape("?1016h")
def disableMousePixelsMode(): String = escape("?1016l")

// Screen
def restoreScreen(): String = escape("?47l")
def saveScreen(): String = escape("?47h")
def altScreen(): String = escape("?1049h")
def exitAltScreen(): String = escape("?1049l")

// Bracketed paste
def enableBracketedPaste(): String = escape("?2004h")
def disableBracketedPaste(): String = escape("?2004l")
def startBracketedPaste(): String = escape("200~")
def endBracketedPaste(): String = escape("201~")

// Session
def setWindowTitle(s: String): String = escape("2;" ++ s)
def setForegroundColor(s: String): String = escape("10;" ++ s)
def setBackgroundColor(s: String): String = escape("11;" ++ s)
def setCursorColor(s: String): String = escape("12;" ++ s)
def showCursor(): String = escape("?25h")
def hideCursor(): String = escape("?25l")

def applyForegroundColor(color: Color): String =
escape(color.toEscapeSeq ++ "m")

def applyBackgroundColor(color: Color): String =
escape("48;" ++ color.toEscapeSeq ++ "m")

/// Inline images
/// See https://iterm2.com/documentation-images.html for protocol description
/// See [imgcat](https://iterm2.com/utilities/imgcat) for more usage details
def inlineImage(size: Int, base64Image: String) =
escape("1337;File=size=" ++ size.show ++ ";inline=1:" ++ base64Image ++ "\u0007")

/// Hyperlinks
/// https://iterm2.com/documentation-escape-codes.html
def hyperlink(text: String, url: String): String =
escapeOSC("8;;") ++ url ++ "\u0007" ++ text ++ escapeOSC("8;;") ++ "\u0007"

def attention(value: String) =
escapeOSC("1337;") ++ "RequestAttention=" ++ value ++ "\u0007"

namespace Screen {
def clear(): String = eraseDisplay(2) ++ cursorPosition(1, 1)
def clearLine(): String = eraseEntireLine()
}

def red(text: String) = Formatted::colored(text, Color::RED)
def green(text: String) = Formatted::colored(text, Color::GREEN)
def yellow(text: String) = Formatted::colored(text, Color::YELLOW)
def blue(text: String) = Formatted::colored(text, Color::BLUE)
def magenta(text: String) = Formatted::colored(text, Color::MAGENTA)
def cyan(text: String) = Formatted::colored(text, Color::CYAN)
def white(text: String) = Formatted::colored(text, Color::WHITE)

def dim(text: String) = Formatted::colored(text, Escape::FAINT)
def bold(text: String) = Formatted::colored(text, Escape::BOLD)
def underline(text: String) = Formatted::colored(text, Escape::UNDERLINE)
def italic(text: String) = Formatted::colored(text, Escape::ITALIC)

interface Formatted {
def supportsEscape(escape: String): Bool
}

namespace Formatted {
/// Run given block of code, allowing all formatting
def formatting[R] { prog : => R / Formatted }: R =
try { prog() } with Formatted {
def supportsEscape(escape: String) = resume(true)
}

/// Run given block of code, ignoring all formatting
def noFormatting[R] { prog : => R / Formatted }: R =
try { prog() } with Formatted {
def supportsEscape(escape: String) = resume(false)
}

def tryEmit(escape: String): String / Formatted =
if (do supportsEscape(escape)) escape else ""

def colored(text: String, escape: String): String / Formatted =
tryEmit(escape) ++ text ++ tryEmit(Escape::RESET)

def colored(text: String, color: Color): String / Formatted =
tryEmit(color.applyForegroundColor) ++ text ++ tryEmit(Escape::RESET)
}

0 comments on commit 92a6e0f

Please sign in to comment.