Compare commits

..

25 commits

Author SHA1 Message Date
Alexander Gehrke 9ed4e9efd7 Update examples 2025-01-09 19:26:28 +01:00
Alexander Gehrke 775abb29d1 Remove markdown include (which used an external binary), add list templates 2025-01-09 18:32:31 +01:00
Alexander Gehrke e19fe276c4 Some image protocol improvements 2025-01-09 17:58:26 +01:00
Alexander Gehrke 0bcc8bc656 fix: ensure cursor is hidden again after interactive command 2024-05-22 03:44:40 +02:00
Alexander Gehrke d6e24a2499 development version of terminal escape code talk 2024-05-20 23:51:34 +02:00
Alexander Gehrke dabbd75c48 feat: showImage: center and scale image by default 2024-05-20 23:50:36 +02:00
Alexander Gehrke 35a6dfbe53 refactor: improve terminal handling 2024-05-20 23:50:06 +02:00
Alexander Gehrke 38632a992d refactor: rename padLeft to indent 2024-05-20 23:47:17 +02:00
Alexander Gehrke add8e9a714 fix: presentation navigation now works correctly again 2024-05-20 23:47:17 +02:00
Alexander Gehrke 3ad18addb0 update oslib 2024-05-20 23:43:17 +02:00
Alexander Gehrke 8111309560 Handle escape codes in text for centering 2024-05-19 02:13:11 +02:00
Alexander Gehrke 2dc7e0a0e4 Add untyped command wrapper 2024-05-18 14:30:05 +02:00
Alexander Gehrke 1ea8c0faec remove unused mill script 2024-05-18 14:29:52 +02:00
Alexander Gehrke 3b2d97d45a Large refactor and cleanup, Kitty graphics WIP 2024-05-18 12:37:15 +02:00
Alexander Gehrke 3b7afa3d90 Versioning for alpha versions is hard 2022-09-21 15:50:20 +02:00
Alexander Gehrke 517720c720 Readd incomplete changes, now without missing apply method 2022-09-21 15:48:28 +02:00
Alexander Gehrke 0d797ea684 Revert incomplete "Allow hiding of TypedCommand cmd and output" 2022-09-21 15:33:34 +02:00
Alexander Gehrke 48680338bf Fix Start command 2022-05-25 19:09:45 +02:00
Alexander Gehrke a6f22e0a08 Add publishing config 2022-05-23 23:54:07 +02:00
Alexander Gehrke 18c0f6ea7a Refactor keymap to allow showing help 2022-05-23 23:17:34 +02:00
Alexander Gehrke 0acbca2e62 Improve terminal escape handling 2022-05-23 23:16:35 +02:00
Alexander Gehrke 2b7a4e8d26 clean up implicits 2022-05-23 23:14:55 +02:00
Alexander Gehrke 7ab755c540 Put default theme into companion object 2022-05-23 23:12:53 +02:00
Alexander Gehrke afa854c55d Fix whitespace errors 2022-05-14 11:07:59 +02:00
Alexander Gehrke f955a7563c Port to Scala 3 2022-05-08 20:27:14 +02:00
30 changed files with 1997 additions and 768 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*.bak
out/

View file

@ -1,26 +0,0 @@
import mill._, scalalib._, publish._
import $ivy.`com.lihaoyi::mill-contrib-bloop:0.9.5`
object copret extends ScalaModule with PublishModule {
def scalaVersion = "2.13.3"
def publishVersion = "0.0.1"
def pomSettings = PomSettings(
description = "Use ammonite scripts for command line presentations",
organization = "de.qwertyuiop",
versionControl = VersionControl.github("crater2150", "copret"),
url = "https://qwertyuiop.de/copret/",
licenses = Seq(License.MIT),
developers = Seq(
Developer("crater2150", "Alexander Gehrke", "https://github.com/crater2150")
)
)
def ivyDeps = Agg(
ivy"org.jline:jline:3.19.0",
ivy"com.lihaoyi::ammonite-ops:2.3.8",
ivy"com.lihaoyi::fansi:0.2.10",
)
}

12
copret/.scalafmt.conf Normal file
View file

@ -0,0 +1,12 @@
version = "3.7.10"
maxColumn = 120
assumeStandardLibraryStripMargin = true
align.preset = most
align.multiline = true
rewrite.rules = [SortModifiers]
trailingCommas = always
runner.dialect = scala3
includeNoParensInSelectChains = false
optIn.breakChainOnFirstMethodDot = false
rewrite.scala3.insertEndMarkerMinLines = 30

11
copret/project.scala Normal file
View file

@ -0,0 +1,11 @@
//> using scala 3.3
//> using packaging.packageType library
//> using publish.organization de.qwertyuiop
//> using publish.name copret
//> using publish.version 0.0.2
//> using dep org.typelevel::cats-core:2.12.0
//> using dep org.jline:jline:3.28.0
//> using dep com.lihaoyi::os-lib:0.11.3
//> using dep com.lihaoyi::fansi:0.5.0
//> using test.dep com.lihaoyi::utest:0.8.4

View file

@ -0,0 +1,125 @@
package de.qwertyuiop.copret
import os.Path
import Terminal.*
import syntax.*
case class Presentation(
slides: Vector[Slide],
meta: Map[String, String] = Map.empty,
):
def start(using keymap: Keymap = Keymap.default) =
Terminal.enterRawMode()
Terminal.hideCursor()
Thread.sleep(1000)
run()
import Presentation._
def run()(using keymap: Keymap) =
logger.info("Starting presentation")
import SlideAction.*
@annotation.tailrec
def rec(currentPos: Int, action: SlideAction): Unit =
logger.info(s"Executing slide $currentPos with action $action")
inline def redraw() =
logger.info(s"redrawing ${currentPos}")
// rec(currentPos - 1, QuickNext)
navigate(currentPos, true, 0)(executeQuick)
inline def navigate(pos: Int, condition: Boolean, direction: Int)(
executor: Int => Slide => Unit,
) =
logger.info(s"Navigating from ${pos}, condition is $condition, direction is ${direction}")
if condition then
executor(pos + direction)(slides(pos + direction))
rec(pos + direction, waitkey)
else rec(pos, waitkey)
inline def runInteractive(cmd: Vector[String], path: Path) =
logger.info(s"Running interactive command ${cmd} with working directory ${path}")
Terminal.showCursor()
try os.proc(cmd).call(cwd = path)
catch case _ => ()
Terminal.hideCursor()
Terminal.clear()
redraw()
action match
case Start => navigate(0, true, 0)(executeSlide)
case Next => navigate(currentPos, currentPos + 1 < slides.size, 1)(executeSlide)
case QuickNext => navigate(currentPos, currentPos + 1 < slides.size, 1)(executeQuick)
case Prev => navigate(currentPos, currentPos > 0, -1)(executeQuick)
case Interactive(cmd, path) => runInteractive(cmd, path)
case Goto(target) =>
for i <- 0 until target
do executeSilent(i)()
rec(target - 1, QuickNext)
case GotoSelect =>
promptSlide() match
case Some(i) => rec(currentPos, Goto(i))
case None => redraw()
case Help =>
Terminal.clear()
println(keymap.help)
waitkey
redraw()
case Other(codes) =>
Terminal.printStatus(action.show)
rec(currentPos, waitkey)
case Quit =>
Terminal.showCursor()
end match
end rec
rec(0, Start)
end run
def promptSlide() =
val maxSlide = slides.size
prompt(s"Go to slide (1 - $maxSlide):", _.toIntOption)(
(res, input) => res.filter((1 to maxSlide).contains).isEmpty && input.nonEmpty,
in => s"No such slide: $in (empty input to abort)",
).map(_ - 1)
def executeSlide(pos: Int)(
slide: Slide = slides(pos),
): Unit = slide match
case Paragraph(contents) => println(contents)
case Clear => Terminal.clear()
case PauseKey => waitkey(using Keymap.empty)
case Pause(msec) => Thread.sleep(msec)
case Image(file, None) => println(KittyGraphicsProtocol.showImage(file))
case Image(file, Some(ImageSize(w, h, aspect))) =>
import KittyGraphicsProtocol.Sizing.*
println(KittyGraphicsProtocol.showImage(file, Absolute(w.toInt), Absolute(h.toInt), aspect)) // TODO
case cmd: TypedCommand[_] => cmd.show()
case Silent(actions) => actions()
case Group(slides) => slides.foreach(executeSlide(pos))
case lios @ LazyIOSlide(_, display) => executeSlide(pos)(lios.genSlide())
case Meta(genSlide) => executeSlide(pos)(genSlide(this, pos))
def executeQuick(pos: Int)(
slide: Slide = slides(pos),
): Unit = slide match
case Pause(msec) => ()
case PauseKey => ()
case cmd: TypedCommand[_] => cmd.quickShow()
case Group(slides) => slides.foreach(executeQuick(pos))
case lios @ LazyIOSlide(_, display) => executeQuick(pos)(lios.genSlide())
case _ => executeSlide(pos)(slide)
def executeSilent(pos: Int)(
slide: Slide = slides(pos),
): Unit = slide match
case cmd: TypedCommand[_] => cmd.force()
case Group(slides) => slides.foreach(executeSilent(pos))
case lios @ LazyIOSlide(_, display) =>
executeSilent(pos)(lios.genSlide())
case Paragraph(_) | Image(_, _) | Clear | Meta(_) => ()
case _ => executeQuick(pos)(slide)
end Presentation

View file

@ -1,52 +1,51 @@
package de.qwertyuiop.copret
import de.qwertyuiop.copret.syntax._
import ammonite.ops.{%%, pwd}
case class Theme(styles: Map[String, fansi.Attrs], figletFonts: Map[String, String]) {
def %%(command: os.Shellable*)(cwd: os.Path = null): os.CommandResult =
os.proc(command).call(cwd = cwd)
case class Theme(styles: Map[String, fansi.Attrs], figletFonts: Map[String, String]):
def style(key: String, default: fansi.Attrs = fansi.Attrs()) =
styles.getOrElse(key, default)
def font(key: String, default: String) =
figletFonts.getOrElse(key, default)
def extend(newStyles: Map[String, fansi.Attrs]) = copy(styles = styles ++ newStyles)
def ++(newStyles: Map[String, fansi.Attrs]) = copy(styles = styles ++ newStyles)
def ++(newStyles: Map[String, fansi.Attrs]) = copy(styles = styles ++ newStyles)
def extend(newFonts: Map[String, String])(implicit d: DummyImplicit) = copy(figletFonts = figletFonts ++ newFonts)
def ++(newFonts: Map[String, String])(implicit d: DummyImplicit) = copy(figletFonts = figletFonts ++ newFonts)
}
object Theme {
implicit val default = Theme(Map(
"titleLine" -> (fansi.Bold.On ++ fansi.Color.DarkGray),
"code" -> fansi.Color.Yellow
),
Map("titleLine" -> "pagga")
)
}
def ++(newFonts: Map[String, String])(implicit d: DummyImplicit) = copy(figletFonts = figletFonts ++ newFonts)
object Format {
def alignRight(str: String, padding: Int = 2) =" " * (columns - str.length - padding) + str + " " * padding
object Theme:
given default: Theme = Theme(
Map(
"titleLine" -> (fansi.Bold.On ++ fansi.Color.DarkGray),
"code" -> fansi.Color.Yellow,
),
Map("titleLine" -> "smbraille"),
)
object Format:
def alignRight(str: String, padding: Int = 2) = " " * (columns - str.length - padding) + str + " " * padding
def center(str: String) = " " * ((columns - str.length) / 2) + str
def figlet(str: String, font: String) = %%("figlet", "-t", "-f", font, str)(pwd).out.string
def figlet(str: String, font: String): String = new String(%%("figlet", "-t", "-f", font, str)().out.bytes)
def centerLines(str: String) = str.split("\n").map(center).mkString("\n")
def centerBlock(str: String) = {
val lines = str.split("\n")
val maxLen = lines.map(_.length).max
val pad = " " * ((columns - maxLen) / 2)
def centerBlock(str: String) =
val lines = str.split("\n")
val maxLen = lines.map(s => Terminal.stripEscapes(s).length).max
val pad = " " * ((columns - maxLen) / 2)
lines.map(pad + _).mkString("\n")
}
def distribute(texts: String*) = {
val totalPad = columns - texts.map(_.length).sum
val numPads = texts.size - 1
val pad = " " * (totalPad / numPads)
def distribute(texts: String*) =
val totalPad = columns - texts.map(s => Terminal.stripEscapes(s).length).sum
val numPads = texts.size - 1
val pad = " " * (totalPad / numPads)
texts.init.mkString(pad) + pad + " " * (totalPad % numPads) + texts.last
}
private[copret] val ticks = raw"`([^`]*)`".r
}
/* vim:set tw=120: */

229
copret/src/images.scala Normal file
View file

@ -0,0 +1,229 @@
package de.qwertyuiop.copret
import Terminal.*
import scala.util.Try
import javax.imageio.ImageIO
object KittyGraphicsProtocol:
val MaxID = 4294967295L // max 32-bit unsigned
def checkSupport() =
queryTerm(s"${apc}Gi=${KittyGraphicsProtocol.MaxID},s=1,v=1,a=q,t=d,f=24;AAAA${st}${csi}c")
.contains(s"${apc}Gi=${KittyGraphicsProtocol.MaxID}")
def imageSize(img: os.Path) =
Try(ImageIO.read(img.toIO)).toOption.map: image =>
SizePx(image.getWidth, image.getHeight)
enum Sizing:
case Absolute(pixels: Int)
case Relative(ratio: Double)
case Infer
case class SizePx(width: Int, height: Int)
case class SizeCells(width: Int, height: Int)
case class ImageSize(px: SizePx, cells: SizeCells)
def fitCellsToScreen(sizeCells: SizeCells, termSize: TermCells): SizeCells =
val widthRatio = sizeCells.width.toDouble / termSize.cols
val heightRatio = sizeCells.height.toDouble / termSize.rows
if widthRatio > 1 || heightRatio > 1 then
val ratio = widthRatio max heightRatio
SizeCells((sizeCells.width / ratio).toInt, (sizeCells.height / ratio).toInt)
else sizeCells
def calculateSize(
size: SizePx,
requestedWidth: Sizing,
requestedHeight: Sizing,
keepAspect: Boolean,
termSize: TermSize,
): ImageSize =
import Sizing.*
def scale(dimensionPx: Int, requestedPx: Sizing): Int =
requestedPx match
case Absolute(pixels) => pixels
case Relative(ratio) => (ratio * dimensionPx).toInt
case Infer => dimensionPx
val cell = SizePx(termSize.pixels.width / termSize.cells.cols, termSize.pixels.height / termSize.cells.rows)
val scaledWidth = scale(size.width, requestedWidth)
val scaledHeight = scale(size.height, requestedHeight)
val pixels =
if !keepAspect then SizePx(scaledWidth, scaledHeight)
else
val widthRatio = scaledWidth.toDouble / size.width
val heightRatio = scaledHeight.toDouble / size.height
if widthRatio < heightRatio then SizePx(scaledWidth, (size.height * widthRatio).toInt)
else SizePx((size.width * heightRatio).toInt, scaledHeight)
val sizeCells = SizeCells(pixels.width / cell.width, pixels.height / cell.height)
ImageSize(pixels, sizeCells)
end calculateSize
def showImage(img: os.Path): String = showImage(img, Sizing.Infer, Sizing.Infer, true)
def showImage(
img: os.Path,
requestedWidth: Sizing,
requestedHeight: Sizing,
keepAspect: Boolean,
fitToScreen: Boolean = true,
): String =
import java.util.Base64
(
for
_ <- Option.when(checkSupport())(true)
termSize <- Terminal.getSize()
cursorPos <- Terminal.getCursorPos()
sizeOrig <- imageSize(img)
ImageSize(pixels, cells) = calculateSize(sizeOrig, requestedWidth, requestedHeight, keepAspect, termSize)
yield
val size =
ImageSize(
pixels,
if fitToScreen then fitCellsToScreen(cells, termSize.cells)
else cells,
)
val image = Base64.getEncoder.encodeToString(os.read.bytes(img))
val cell = SizePx(termSize.pixels.width / termSize.cells.cols, termSize.pixels.height / termSize.cells.rows)
val commonParams = s"s=${sizeOrig.width},v=${sizeOrig.height},c=${size.cells.width},r=${size.cells.height}"
logger.info(s"Image size (px): ${sizeOrig.width} x ${sizeOrig.height}")
logger.info(s"Cellsize (px): ${cell.width} x ${cell.height}")
logger.info(s"Preferred size (px) ${size.px}")
logger.info(s"Preferred size (Cells) ${size.cells}")
if image.length > 4096 then
val chunks = image.grouped(4096).toVector
s"${apc}Gf=100,t=d,m=1,a=T,${commonParams};${chunks.head}${st}" +
chunks.tail.init.map(c => s"${apc}Gm=1;${c}${st}").mkString +
s"${apc}Gm=0;${chunks.last}${st}"
else s"${apc}Gf=100,t=d,a=T,${commonParams};${image}${st}"
).getOrElse("Could not show image")
end showImage
trait Param:
def code: String
enum Action(val encoded: Char) extends Param:
case Transmit extends Action('t') // - transmit data
case TransmitShow extends Action('T') // - transmit data and display image
case Query extends Action('q') // - query terminal
case Show extends Action('p') // - put (display) previous transmitted image
case Delete(what: DelType, free: Boolean) extends Action('d') // - delete image
case Animation extends Action('a') // - control animation
case AnimationFrame extends Action('f') // - transmit data for animation frames
case AnimationCompose extends Action('c') // - compose animation frames
def code: String = this match
case Delete(action, free) =>
"d=" + (if free then action.code.capitalize else action.code)
case other => s"a=${other.encoded}"
enum Responses(val code: String) extends Param:
case All extends Responses("q=0")
case NoOk extends Responses("q=1")
case NoError extends Responses("q=2")
enum Format(val code: String) extends Param:
case RGB(width: Int, height: Int) extends Format(s"f=24,s=${width},v=${height}")
case RGBA(width: Int, height: Int) extends Format(s"f=32,s=${width},v=${height}")
case PNG extends Format("f=100")
enum Medium(val code: String) extends Param:
case Direct extends Medium("t=d")
case File extends Medium("t=f")
case TempFile extends Medium("t=t")
case SharedMemory extends Medium("t=s")
enum AnimationState(val code: String) extends Param:
case Stop extends AnimationState("s=1")
case WaitFrame extends AnimationState("s=2")
case Run extends AnimationState("s=3")
private def delType(what: Char, freeData: Boolean) =
s"a=d,d=${if freeData then what.toUpper else what}"
enum DelType extends Param:
case AllPlacements
case ById(id: Int)
case ByNumber(num: Int)
case AtCursor
case Frames
case AtPosition(x: Int, y: Int, z: Int = -1)
case ByIdRange(from: Int, to: Int)
case ByColumn(x: Int)
case ByRow(y: Int)
case ByZIndex(z: Int)
def code: String = this match
case AllPlacements => "a"
case ById(id) => s"i,i=$id"
case ByNumber(num) => s"n,I=$num"
case AtCursor => "c"
case Frames => "f"
case AtPosition(x, y, z) =>
val (what, zarg) = if z >= 0 then ("q", s",z=$z") else ("p", "")
what + s",x=$x,y=$y" + zarg
case ByIdRange(x, y) => s"r,x=$x,y=$y"
case ByColumn(x) => s"x,x=$x"
case ByRow(y) => s"y,y=$y"
case ByZIndex(z) => s"z,z=$z"
def optionInts(values: (Int, Char)*) =
values.flatMap((value, key) => Option.when(value >= 0)(s"${key}=${value}")).mkString(",")
case class SimpleParam(val code: String) extends Param
def Filesize(bytes: Int) = SimpleParam(s"S=$bytes")
def Offset(bytes: Int) = SimpleParam(s"O=$bytes")
def ImageID(id: Int) = SimpleParam(s"i=$id")
def ImageNumber(num: Int) = SimpleParam(s"I=$num")
def PlacementID(id: Int) = SimpleParam(s"p=$id")
def ZIndex(zindex: Int) = SimpleParam(s"z=$zindex")
def Parent(id: Int) = SimpleParam(s"P=$id")
def ParentPlacement(id: Int) = SimpleParam(s"Q=$id")
def RelativeOffset(x: Int = -1, y: Int = -1) = SimpleParam(
optionInts((x, 'H'), (y, 'V')),
)
val CompressZlib = SimpleParam("o=z")
val ContinueChunk = SimpleParam("m=1")
val DontMoveCursor = SimpleParam("C=1")
val UnicodePlaceholder = SimpleParam("U=1")
def Crop(
originX: Int = -1,
originY: Int = -1,
width: Int = -1,
height: Int = -1,
) =
SimpleParam(
optionInts((originX, 'x'), (originY, 'y'), (width, 'w'), (height, 'h')),
)
def AnimFrameUpdate(
originX: Int = -1,
originY: Int = -1,
baseID: Int = -1,
editID: Int = -1,
) =
SimpleParam:
optionInts((originX, 'x'), (originY, 'y'), (baseID, 'c'), (editID, 'r'))
def AnimGap(millis: Int) = SimpleParam(s"z=$millis")
val AnimUseOverwrite = SimpleParam("X=1")
def CellOffset(x: Int = -1, y: Int = -1) = SimpleParam:
optionInts((x, 'X'), (y, 'Y'))
def DisplayCells(cols: Int = -1, rows: Int = -1) = SimpleParam:
optionInts((cols, 'c'), (rows, 'r'))
end KittyGraphicsProtocol

View file

@ -1,86 +1,127 @@
package de.qwertyuiop.copret
import ammonite.ops.Path
import os.Path
sealed trait SlideAction
case object Start extends SlideAction
case class Goto(slideIndex: Int) extends SlideAction
case object GotoSelect extends SlideAction
case object Prev extends SlideAction
case object Next extends SlideAction
case object QuickNext extends SlideAction
case object Quit extends SlideAction
case class Interactive(cmd: Vector[String], wd: Path) extends SlideAction
case class Other(code: List[Int]) extends SlideAction
enum SlideAction:
case Start
case Goto(slideIndex: Int)
case GotoSelect
case Prev
case Next
case QuickNext
case Quit
case Help
case Interactive(cmd: Vector[String], wd: Path)
case Other(code: List[Int])
object SlideAction {
def runForeground(cmd: String*)(implicit wd: Path) = Interactive(cmd.toVector, wd)
}
def show: String = this match
case Start => "go to first slide"
case Goto(slideIndex: Int) => s"jump directly to slide $slideIndex"
case GotoSelect => "jump to slide"
case Prev => "previous slide (skip animations)"
case Next => "next slide"
case QuickNext => "next slide (skip animations)"
case Quit => "quit"
case Help => "show help"
case Interactive(cmd: Vector[String], wd: Path) =>
s"execute command \"${cmd.mkString(" ")}\""
case Other(code: List[Int]) => s"Unknown key sequence: $code"
object SlideAction:
def runForeground(cmd: String*)(implicit wd: Path) =
Interactive(cmd.toVector, wd)
case class Keymap(bindings: Map[List[Int], SlideAction]) {
def apply(keycode: List[Int]): SlideAction = bindings.getOrElse(keycode, Other(keycode))
import SlideAction.*
def extend(newBindings: Map[List[Int], SlideAction]) = Keymap(bindings ++ newBindings)
def ++(newBindings: Map[List[Int], SlideAction]) = Keymap(bindings ++ newBindings)
}
object Keymap {
val empty = Keymap(Map())
val default = Keymap(Map(
Key.Up -> Prev,
Key.Left -> Prev,
Key.PageUp -> Prev,
Key('k') -> Prev,
Key(' ') -> Next,
Key('j') -> Next,
Key.Down -> QuickNext,
Key.Right -> QuickNext,
Key.PageDown -> QuickNext,
Key('q') -> Quit,
Key('g') -> Start,
Key('s') -> GotoSelect,
))
case class Keymap(bindings: Map[Key, SlideAction]):
private val lookup = bindings.map((k, v) => k.codes -> v)
def apply(keycode: List[Int]): SlideAction =
lookup.getOrElse(keycode, Other(keycode))
}
def extend(newBindings: Map[Key, SlideAction]) = Keymap(
bindings ++ newBindings
)
def ++(newBindings: Map[Key, SlideAction]) = extend(newBindings)
def help: String =
bindings.toSeq
.sortBy(_._2.toString)
.map((k, v) => k.show.padTo(8, ' ') + " " + v.show)
.mkString("\n")
object Key {
object codes {
val Esc = 27
val Backspace = 127
}
val Esc = List(codes.Esc)
val Backspace = List(codes.Backspace)
val Delete = List(codes.Esc, '[', '3', '~')
object Keymap:
val empty = Keymap(Map())
// format: off
val default = Keymap(Map(
Key.Up -> Prev,
Key.Left -> Prev,
Key.PageUp -> Prev,
Key('k') -> Prev,
Key.Space -> Next,
Key('j') -> Next,
Key.Down -> QuickNext,
Key.Right -> QuickNext,
Key.PageDown -> QuickNext,
Key('q') -> Quit,
Key('g') -> Start,
Key.Enter -> Start,
Key('s') -> GotoSelect,
Key('?') -> Help,
))
//format: on
val PageUp = List(codes.Esc, '[', '5', '~')
val PageDown = List(codes.Esc, '[', '6', '~')
enum Key:
case Code(name: String, codepoints: List[Int])
case Printable(char: Char)
val Home = List(codes.Esc, '[', 'H')
val End = List(codes.Esc, '[', 'F')
def codes: List[Int] =
this match
case Code(_, cp) => cp
case Printable(char) => List(char.toInt)
val F1 = List(codes.Esc, 'P')
val F2 = List(codes.Esc, 'Q')
val F3 = List(codes.Esc, 'R')
val F4 = List(codes.Esc, 'S')
def show: String =
this match
case Code(name, _) => name
case Printable(c) => c.toString
val F5 = List(codes.Esc, '1', '5', '~')
val F6 = List(codes.Esc, '1', '7', '~')
val F7 = List(codes.Esc, '1', '8', '~')
val F8 = List(codes.Esc, '1', '9', '~')
object Key:
def apply(char: Char): Key = Printable(char)
def apply(name: String, codepoints: Int*): Key = Code(name, codepoints.toList)
object codes:
val esc = 27
val backspace = 127
val F9 = List(codes.Esc, '2', '0', '~')
val F10 = List(codes.Esc, '2', '1', '~')
val F11 = List(codes.Esc, '2', '3', '~')
val F12 = List(codes.Esc, '2', '4', '~')
val Esc = Key("Escape", codes.esc)
val Backspace = Key("Backspace", codes.backspace)
val Delete = Key("Delete", codes.esc, '[', '3', '~')
val Tab = List('\t')
val PageUp = Key("PageUp", codes.esc, '[', '5', '~')
val PageDown = Key("PageDown", codes.esc, '[', '6', '~')
val Up = List(codes.Esc, '[', 'A')
val Down = List(codes.Esc, '[', 'B')
val Right = List(codes.Esc, '[', 'C')
val Left = List(codes.Esc, '[', 'D')
val Home = Key("Home", codes.esc, '[', 'H')
val End = Key("End", codes.esc, '[', 'F')
def apply(char: Char): List[Int] = List(char.toInt)
}
val F1 = Key("F1", codes.esc, 'P')
val F2 = Key("F2", codes.esc, 'Q')
val F3 = Key("F3", codes.esc, 'R')
val F4 = Key("F4", codes.esc, 'S')
val F5 = Key("F5", codes.esc, '1', '5', '~')
val F6 = Key("F6", codes.esc, '1', '7', '~')
val F7 = Key("F7", codes.esc, '1', '8', '~')
val F8 = Key("F8", codes.esc, '1', '9', '~')
val F9 = Key("F9", codes.esc, '2', '0', '~')
val F10 = Key("F10", codes.esc, '2', '1', '~')
val F11 = Key("F11", codes.esc, '2', '3', '~')
val F12 = Key("F12", codes.esc, '2', '4', '~')
val Space = Key("<space>", ' ')
val Tab = Key("<tab>", '\t')
val Enter = Key("<enter>", '\n')
val Up = Key("Up", codes.esc, '[', 'A')
val Down = Key("Down", codes.esc, '[', 'B')
val Right = Key("Right", codes.esc, '[', 'C')
val Left = Key("Left", codes.esc, '[', 'D')
/* vim:set tw=120: */

16
copret/src/logger.scala Normal file
View file

@ -0,0 +1,16 @@
package de.qwertyuiop.copret
object logger:
import java.util.logging.*
val log = Logger.getLogger("copret")
val fh = FileHandler("/tmp/copret.log")
log.addHandler(fh)
log.setUseParentHandlers(false)
System.setProperty(
"java.util.logging.SimpleFormatter.format",
"%1$tY-%1$tm-%1$tdT%1$tH:%1$tM:%1$tS.%1$tN %1$Tp %2$s: %4$s: %5$s%6$s%n",
)
fh.setFormatter(new SimpleFormatter())
fh.setLevel(Level.ALL)
export log.*

View file

@ -1,190 +1,139 @@
package de.qwertyuiop.copret
import ammonite.ops._
import Terminal._
import syntax._
import os.Path
case class Presentation(slides: Vector[Slide], meta: Map[String, String] = Map.empty) {
def start(keymap: Keymap = Keymap.default) = {
Terminal.enterRawMode()
run(keymap)
}
import Presentation._
def run(implicit k: Keymap) = {
@annotation.tailrec def rec(p: Presentation, pos: Int, action: SlideAction): Unit = {
action match {
case Start =>
executeSlide(p, pos)()
rec(p, 1, waitkey)
case Next | Other(_) =>
if(pos + 1 < p.slides.size) {
executeSlide(p, pos + 1)()
rec(p, pos + 1, waitkey)
} else rec(p, pos, waitkey)
case QuickNext =>
if(pos + 1 < p.slides.size) {
executeQuick(p, pos + 1)()
rec(p, pos + 1, waitkey)
} else rec(p, pos, waitkey)
case Prev =>
if(pos > 0) {
executeQuick(p, pos - 1)()
rec(p, pos - 1, waitkey)
} else rec(p, pos, waitkey)
case Interactive(cmd, path) =>
%(cmd)(path)
rec(p, pos - 1, QuickNext)
case Goto(target) =>
for (i <- 0 until target) executeSilent(p, i)()
rec(p, target - 1, QuickNext)
case GotoSelect =>
val maxSlide = p.slides.size - 1
val target = prompt(s"Go to slide (0 - $maxSlide):", _.toIntOption)(
(res, input) => res.filter((0 to maxSlide).contains).isEmpty && input.nonEmpty,
in => s"No such slide: $in (empty input to abort)"
)
target match {
case Some(i) => rec(p, pos, Goto(i))
case None => rec(p, pos - 1, QuickNext)
}
case Quit => ()
}
}
rec(this, 0, Start)
}
}
object Presentation {
def executeSlide(p: Presentation, pos: Int)(slide: Slide = p.slides(pos)): Unit = slide match {
case Paragraph(contents) => println(contents)
case Clear => print("\u001b[2J\u001b[;H")
case PauseKey => waitkey(Keymap.empty)
case Pause(msec) => Thread.sleep(msec)
case incMd @ IncludeMarkdown(_) => println(incMd.markdownBlock())
case Image(file, width, height, keepAspect) => print(Terminal.showImage(file, width, height, keepAspect))
case cmd: TypedCommand[_] => cmd.show()
case Silent(actions) => actions()
case Group(slides) => slides.foreach(executeSlide(p, pos))
case lios @ LazyIOSlide(_, display) => executeSlide(p, pos)(lios.genSlide())
case Meta(genSlide) => executeSlide(p, pos)(genSlide(p, pos))
case other => println("Error: Unknown slide type:"); println(other)
}
def executeQuick(p: Presentation, pos: Int)(slide: Slide = p.slides(pos)): Unit = slide match {
case Pause(msec) => ()
case PauseKey => ()
case cmd: TypedCommand[_] => cmd.quickShow()
case Group(slides) => slides.foreach(executeQuick(p, pos))
case lios @ LazyIOSlide(_, display) => executeQuick(p, pos)(lios.genSlide())
case _ => executeSlide(p, pos)(slide)
}
def executeSilent(p: Presentation, pos: Int)(slide: Slide = p.slides(pos)): Unit = slide match {
case cmd: TypedCommand[_] => cmd.force()
case Group(slides) => slides.foreach(executeSilent(p, pos))
case lios @ LazyIOSlide(_, display) => executeSilent(p, pos)(lios.genSlide())
case Paragraph(_) | Image(_,_,_,_) | Clear | IncludeMarkdown(_) | Meta(_) => ()
case _ => executeQuick(p, pos)(slide)
}
}
import Terminal.*
import syntax.*
case class ImageSize(width: Double, height: Double, keepAspect: Boolean)
sealed trait Slide
case class Paragraph(contents: fansi.Str) extends Slide
case class IncludeMarkdown(path: Path) extends Slide {
def markdownBlock() = %%%("/usr/bin/mdcat", "--columns", (columns * 0.8).toInt.toString, path.toString)(pwd).block
}
case class Image(path: Path, width: String = "100%", height: String = "100%", keepAspect: Boolean = true) extends Slide
case object Clear extends Slide
case class Pause(millisec: Long) extends Slide
case object PauseKey extends Slide
case class Paragraph(contents: String) extends Slide:
def centerVertical(height: Int): Paragraph =
val lines = contents.toString.count(_ == '\n') + 1
val pad = "\n" * ((height - lines) / 2)
Paragraph(pad + contents + pad)
object Paragraph:
def apply(str: fansi.Str): Paragraph = Paragraph(str.toString)
case class Image(path: Path, sizing: Option[ImageSize]) extends Slide
object Image:
def apply(path: Path) = new Image(path, None)
def scaled(path: Path, width: Double, height: Double, keepAspect: Boolean) =
Image(path, Some(ImageSize(width, height, keepAspect)))
case object Clear extends Slide
case class Pause(millisec: Long) extends Slide
case object PauseKey extends Slide
case class Meta(contents: (Presentation, Int) => Slide) extends Slide
case class TypedCommand[T](exec: T => String, display: String, cmd: T) extends Slide {
case class TypedCommand[T](
exec: T => String,
display: String,
cmd: T,
cmdIsHidden: Boolean,
outputIsHidden: Boolean,
) extends Slide:
private lazy val _output = exec(cmd)
def output = _output
def display(s: String): TypedCommand[T] = TypedCommand(exec, s, cmd)
def output = _output
def show() = {
prompt()
typeCmd()
print(output)
}
infix def showing(s: String): TypedCommand[T] = TypedCommand(exec, s, cmd)
def quickShow() = {
prompt()
println(display)
print(output)
}
def show() =
force()
if !cmdIsHidden then
prompt()
Terminal.showCursor()
typeCmd()
if !outputIsHidden then print(output)
if !cmdIsHidden then Terminal.hideCursor()
def quickShow() =
force()
if !cmdIsHidden then
prompt()
println(display)
if !outputIsHidden then print(output)
def prompt() = print(fansi.Color.LightGreen("user@host % "))
def force() = _output
def force() = _output
private def typeCmd() = {
for (char <- display) {
def display(s: String) = copy(display = s)
private def typeCmd() =
for char <- display do
print(char)
Thread.sleep(50 + scala.util.Random.nextInt(80))
}
println()
}
/* Conditionally disable execution. Useful for e.g. a debug mode, or a non-interactive mode */
def disable(altDisplay: String = display, condition: Boolean = true) =
if(condition) copy(display = altDisplay, exec = (_:T) => "")
if condition then copy(display = altDisplay, exec = (_: T) => "")
else this
/* Conditionally replace the executed command (but still displaying the same). Useful for e.g. a non-interactive mode,
* where a call to an editor is replaced with a file operation */
def replaceIf(condition: Boolean)(tc: TypedCommand[_]): TypedCommand[_] =
if(condition) tc.display(display)
if condition then tc.showing(display)
else this
end TypedCommand
}
object TypedCommand {
object TypedCommand:
val shell = sys.env.getOrElse("SHELL", "sh")
def run(implicit wd: Path): Vector[String] => String =
c => safe_%%(c)
def run(using Path): Vector[String] => String =
c => runProcess(c)
def runShell(implicit wd: Path): Vector[String] => String =
c => safe_%%(Vector(shell, "-c", c.mkString(" ")))
def runShell(using Path): Vector[String] => String =
c => runProcess(Vector(shell, "-c", c.mkString(" ")))
def runInteractive(implicit wd: Path): Vector[String] => String =
c => { %(c); ""}
def runInteractive(using cwd: Path): Vector[String] => String =
c =>
Terminal.showCursor()
os.proc(c).call(stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit, cwd = cwd)
Terminal.hideCursor()
""
def apply(cmd: String*)(implicit wd: Path): TypedCommand[Vector[String]] =
def apply(cmd: String*)(using Path): TypedCommand[Vector[String]] =
TypedCommand(run, cmd.mkString(" "), cmd.toVector)
def shell(cmd: String*)(implicit wd: Path): TypedCommand[Vector[String]] =
def apply[T](exec: T => String, display: String, cmd: T): TypedCommand[T] =
TypedCommand(exec, display, cmd, false, false)
def shell(cmd: String*)(using Path): TypedCommand[Vector[String]] =
TypedCommand(runShell, cmd.mkString(" "), cmd.toVector)
def fake(cmd: String): TypedCommand[String] =
def fake(cmd: String): TypedCommand[String] =
TypedCommand(_ => "", cmd, cmd)
def interactive(cmd: String*)(implicit wd: Path): TypedCommand[Vector[String]] =
def interactive(cmd: String*)(using Path): TypedCommand[Vector[String]] =
TypedCommand(runInteractive, cmd.mkString(" "), cmd.toVector)
}
def interactiveUntyped(cmd: String*)(using Path): TypedCommand[Vector[String]] =
TypedCommand(runInteractive, cmd.mkString(" "), cmd.toVector, cmdIsHidden = true, outputIsHidden = false)
def untyped(cmd: String*)(using Path): TypedCommand[Vector[String]] =
TypedCommand(exec = run, display = "", cmd.toVector, cmdIsHidden = true, outputIsHidden = false)
end TypedCommand
sealed abstract case class Silent[T] private (doStuff: () => T) extends Slide
object Silent { def apply[T](doStuff: => T) = new Silent(() => doStuff){} }
object Silent:
def apply[T](doStuff: => T) = new Silent(() => doStuff) {}
case class Group(slides: List[Slide]) extends Slide
object Group { def apply(slides: Slide*): Group = Group(slides.toList) }
object Group:
def apply(slides: Slide*): Group = Group(slides.toList)
case class LazyIOSlide[T](runOnce: () => T, display: T => Slide) extends Slide {
case class LazyIOSlide[T](runOnce: () => T, display: T => Slide) extends Slide:
private lazy val data = runOnce()
def genSlide(): Slide = display(data)
}
trait SlideSyntax {
private[copret] class LazyIOSlideBuilder[T](runOnce: => T) {
trait SlideSyntax:
private[copret] class LazyIOSlideBuilder[T](runOnce: => T):
def useIn(display: T => Slide) = LazyIOSlide(() => runOnce, display)
}
def prepare[T](runOnce: => T): LazyIOSlideBuilder[T] = new LazyIOSlideBuilder(runOnce)
}
def prepare[T](runOnce: => T): LazyIOSlideBuilder[T] = LazyIOSlideBuilder(
runOnce,
)
/* vim:set tw=120: */

View file

@ -1,38 +1,34 @@
package de.qwertyuiop.copret
object syntax extends Templates with TerminalSyntax with SlideSyntax {
implicit class PresenterStringExtensions(val str: String) {
import Format._
def code(implicit theme: Theme) = Format.ticks.replaceAllIn(str, m => theme.style("code")("$1").render)
def text(implicit theme: Theme) = Paragraph(str)
def par(implicit theme: Theme) = Paragraph(str.stripMargin.code.padLeft(2))
def style(key: String, default: fansi.Attrs = fansi.Attrs())(implicit theme: Theme) = theme.style(key, default)(str)
object syntax extends Templates with TerminalSyntax with SlideSyntax:
import Format.*
extension (str: String)
def code(using theme: Theme) = Format.ticks.replaceAllIn(str, m => theme.style("code")("$1").render)
def text(using Theme) = Paragraph(str)
def par(using Theme) = Paragraph(str.stripMargin.code.indent(2))
def style(key: String, default: fansi.Attrs)(using theme: Theme) = theme.style(key, default)(str)
def centered = center(str)
def block = centerBlock(str)
def right = alignRight(str)
def right(padding: Int) = alignRight(str, padding)
def padLeft(padding: Int) = {
def centered = center(str)
def block = centerBlock(str)
def right = alignRight(str)
def right(padding: Int) = alignRight(str, padding)
def indent(padding: Int) =
val pad = " " * padding
str.linesIterator.map(pad + _).mkString("\n")
}
def blue = fansi.Color.Blue(str)
def green = fansi.Color.Green(str)
def blue = fansi.Color.Blue(str)
def green = fansi.Color.Green(str)
def yellow = fansi.Color.Yellow(str)
def red = fansi.Color.Red(str)
}
def red = fansi.Color.Red(str)
implicit class PresenterFansiStringExtensions(val str: fansi.Str) {
import Format._
def text(implicit theme: Theme) = Paragraph(str)
def style(key: String, default: fansi.Attrs = fansi.Attrs())(implicit theme: Theme) = theme.style(key, default)(str)
extension (str: fansi.Str)
def text(using Theme) = Paragraph(str.toString)
def style(key: String, default: fansi.Attrs)(using theme: Theme) = theme.style(key, default)(str)
def blue = fansi.Color.Blue(str)
def green = fansi.Color.Green(str)
def blue = fansi.Color.Blue(str)
def green = fansi.Color.Green(str)
def yellow = fansi.Color.Yellow(str)
def red = fansi.Color.Red(str)
}
def red = fansi.Color.Red(str)
end syntax
}
/* vim:set tw=120: */

View file

@ -1,25 +1,64 @@
package de.qwertyuiop.copret
import syntax._
import ammonite.ops.{Path, %, %%, pwd}
import syntax.*
import os.Path
trait Templates {
def titleLine(title: String)(implicit theme: Theme) = Paragraph(
"\n" + Format.figlet(title, theme.font("titleLine", "pagga")).block.blue + "\n"
)
trait Templates:
def titleLine(title: String)(using theme: Theme) = Paragraph(
"\n" + Format.figlet(title, theme.font("titleLine", "pagga")).block.blue + "\n",
)
def header(implicit theme: Theme) = Meta((p, pos) => {
val left = p.meta.getOrElse("author", "")
def header(using theme: Theme) = Meta((p, pos) => {
val left = p.meta.getOrElse("author", "")
val center = p.meta.getOrElse("title", "")
val right = s"${pos} / ${p.slides.size - 1}"
val right = s"${pos + 1} / ${p.slides.size}"
theme.style("titleLine")(Format.distribute(left, center, right)).text
})
def slide(title: String)(slides: Slide*) = Group(Clear :: header :: titleLine(title) :: slides.toList)
def slide(slides: Slide*) = Group(Clear :: header :: slides.toList)
def slide(title: String)(slides: Slide*)(using Theme) = Group(
Clear :: header :: titleLine(title) :: slides.toList,
)
def slide(slides: Slide*)(using Theme) = Group(
Clear :: header :: slides.toList,
)
def markdown(title: String, content: Path) = slide(title)(IncludeMarkdown(content))
def markdown(title: String, content: Path)(using Theme) = slide(title)(
Paragraph(
%%%(
"/usr/bin/mdcat",
"--columns",
(columns * 0.8).toInt.toString,
content.toString,
)(using os.pwd).block,
),
)
lazy val --- = Paragraph(("═" * columns).yellow)
}
def list(bullet: (Int, Int) => String, continued: (Int, Int) => String)(items: String*)(using Theme): Paragraph =
items.zipWithIndex
.map((str, index) => {
def formatFirst(first: String) =
s" ${bullet(index, items.size)} $first"
str.split("\n").toList match
case first :: Nil => formatFirst(first)
case first :: cont =>
formatFirst(first)
+ cont.map(line => s" ${continued(index, items.size)}${line}").mkString("\n", "\n", "")
case Nil => ""
})
.mkString("\n")
.par
def ulist(items: String*)(using Theme) = list(bullet = (_, _) => "✦ ", continued = (_, _) => " ")(items*)
def olist(items: String*)(using Theme) =
def digits(i: Int): Int = math.ceil(math.log10(i)).toInt
list(
bullet = (i, max) => (" " * (digits(max + 1) - digits(i + 1) - 1)) + s"${i + 1}.",
continued = (i, max) => " " * digits(max + 1),
)(items*)
lazy val --- = Paragraph("\n" + ("-" * columns).yellow.toString + "\n")
lazy val === = Paragraph("\n" + ("═" * columns).yellow.toString + "\n")
end Templates
/* vim:set tw=120: */

View file

@ -1,72 +1,134 @@
package de.qwertyuiop.copret
import ammonite.ops.{Path, ShelloutException, pwd, read, %, %%}
import os.Path
import org.jline.terminal.TerminalBuilder
import org.jline.reader.LineReaderBuilder
import scala.io.StdIn
import cats.*
import scala.collection.IndexedSeqView
object Terminal {
def safe_%%(cmd: Vector[String])(implicit wd: Path): String =
try {
%%(cmd).out.string
} catch {
case e: ShelloutException => e.result.err.string
}
object Terminal:
def runProcess(cmd: Vector[String])(using cwd: os.Path): String =
try os.proc(cmd.map(identity)).call(cwd = cwd).out.text()
catch case e: os.SubprocessException => e.result.err.text()
def tryCmd[T](cmd: => T, default: => T) =
try { cmd } catch { case e: ShelloutException => default }
try cmd
catch case e: os.SubprocessException => default
def enterRawMode(): Unit = {
%%("sh", "-c", "stty -icanon min 1 < /dev/tty")(pwd)
%%("sh", "-c", "stty -echo < /dev/tty")(pwd)
}
def enterRawMode(): Unit =
os.proc("sh", "-c", "stty -icanon min 1 < /dev/tty")
.call(stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit)
os.proc("sh", "-c", "stty -echo < /dev/tty").call(stdin = os.Inherit, stdout = os.Inherit, stderr = os.Inherit)
private[copret] lazy val jterm = org.jline.terminal.TerminalBuilder.terminal()
private[copret] lazy val lineReader = LineReaderBuilder.builder().terminal(jterm).build()
private[copret] lazy val jterm = org.jline.terminal.TerminalBuilder.terminal()
private[copret] lazy val lineReader =
LineReaderBuilder.builder().terminal(jterm).build()
def waitkey(implicit keymap: Keymap): SlideAction = {
def waitkey(implicit keymap: Keymap): SlideAction =
// ignore keypresses done during slide animations
while(Console.in.ready()) Console.in.read
while (System.in.available() > 0) System.in.read
var key = scala.collection.mutable.ArrayBuffer[Int]()
key += Console.in.read
while(Console.in.ready)
key += Console.in.read
key += System.in.read
while (System.in.available() > 0)
key += System.in.read
keymap(key.toList)
}
def queryTerm(query: String): String =
val ttyIn = new java.io.FileInputStream("/dev/tty")
val ttyOut = new java.io.PrintStream(new java.io.FileOutputStream("/dev/tty"))
ttyOut.print(query)
var response = scala.collection.mutable.ArrayBuffer[Int]()
response += ttyIn.read()
while (ttyIn.available > 0 && response.last != '\u0007')
response += ttyIn.read()
new String(response.toArray, 0, response.length)
case class EscapeSequence()
def parseEscape(sequence: String): Option[EscapeSequence] = ???
def prompt[T](prefix: String, parse: String => T)(
retry: (T, String) => Boolean = (t: T, s: String) => false,
error: String => String = in => s"Invalid input: $in"
): T = {
val input = lineReader.readLine(prefix + " ")
val result = parse(input)
if(retry(result, input)) {
println(error(input))
prompt(prefix, parse)(retry, error)
}
else result
}
error: String => String = in => s"Invalid input: $in",
): T =
val input = lineReader.readLine(prefix + " ")
val result = parse(input)
if retry(result, input) then
println(error(input))
prompt(prefix, parse)(retry, error)
else result
def isTmux = sys.env.contains("TMUX") || sys.env("TERM").startsWith("screen")
def csi = if (isTmux) "\u001bPtmux\u001b\u001b[" else "\u001b["
def osc = if (isTmux) "\u001bPtmux\u001b\u001b]" else "\u001b]"
def st = if (isTmux) "\u0007\u001b\\" else "\u0007"
def apc = if (isTmux) "\u001bPtmux;\u001b\u001b_" else "\u001b_"
def st = if (isTmux) "\u0007\u001b\\" else "\u001b\\"
def showImage(img: Path, width: String = "100%", height: String = "100%", keepAspect: Boolean = true) = {
def stripEscapes(str: String) =
// match CSI until letter or OSC/APC until ST
str.replaceAll("\u001b(\\[[^@-~]*[@-~]|(\\]|_).*?(\u0007|\u001b\\\\))", "")
def hideCursor() = print(s"${csi}?25l")
def showCursor() = print(s"${csi}?25h")
def cursorTo(row: Int, col: Int) = print(s"${csi}${row};${col}H")
def clear() = print(s"${csi}2J${csi};H")
def printStatus(msg: String): Unit =
cursorTo(jterm.getSize.getColumns, 1)
print(msg)
def showImage(
img: os.Path,
width: String = "100%",
height: String = "100%",
keepAspect: Boolean = true,
) =
import java.util.Base64
val image = Base64.getEncoder.encodeToString(read.bytes(img))
val aspect = if(keepAspect) 1 else 0
s"${osc}1337;File=inline=1;width=$width;height=$height;preserveAspectRatio=$aspect:$image$st"
}
}
val image = Base64.getEncoder.encodeToString(os.read.bytes(img))
val aspect = if (keepAspect) 1 else 0 // TODO
if image.length > 4096 then
val chunks = image.grouped(4096).toVector
private[copret] trait TerminalSyntax {
s"${apc}Gf=100,t=d,m=1,a=T;${chunks.head}${st}" +
chunks.tail.init.map(c => s"${apc}Gm=1;${c}${st}").mkString +
s"${apc}Gm=0;${chunks.last}${st}"
else s"${apc}Gf=100,t=d,a=T;${image}${st}"
private val CursorPosResponse = """\u001b\[(\d+);(\d+)R""".r
def getCursorPos(): Option[CursorPos] =
queryTerm(s"${csi}6n") match
case CursorPosResponse(rows, cols) => Some(CursorPos(cols.toInt, rows.toInt))
case _ => None
def getSizeCells(): Option[TermCells] =
queryTerm(s"${csi}s${csi}999;999H${csi}6n${csi}u") match
case CursorPosResponse(rows, cols) => Some(TermCells(cols.toInt, rows.toInt))
case _ => None
private val SizeResponse = """\u001b\[4;(\d+);(\d+)t""".r
case class TermPixels(width: Int, height: Int)
case class TermCells(cols: Int, rows: Int)
case class TermSize(pixels: TermPixels, cells: TermCells)
case class CursorPos(cols: Int, rows: Int)
def getSizePixels(): Option[TermPixels] = queryTerm(s"${csi}14t") match
case SizeResponse(rows, cols) => Some(TermPixels(cols.toInt, rows.toInt))
case _ => None
def getSize(): Option[TermSize] =
for
pixels <- getSizePixels()
cells <- getSizeCells()
yield TermSize(pixels, cells)
end Terminal
private[copret] trait TerminalSyntax:
import Terminal._
def %%%(cmd: String*)(implicit wd: Path) = safe_%%(cmd.toVector)
def columns = jterm.getSize.getColumns
def rows = jterm.getSize.getRows
}
def %%%(cmd: String*)(using cwd: os.Path): String = runProcess(cmd.toVector)
def columns = jterm.getSize.getColumns
def rows = jterm.getSize.getRows
/* vim:set tw=120: */

1
examples/git/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
demoRepo

462
examples/git/git-presentation.sc Executable file
View file

@ -0,0 +1,462 @@
#!/usr/bin/env -S scala-cli shebang
//>using scala 3.3
//>using dep de.qwertyuiop::copret:0.0.2
import de.qwertyuiop.copret._
import de.qwertyuiop.copret.syntax.*
import TypedCommand.{interactive, shell => sh}, Format.figlet
import os.{pwd, Path}
/* Configuration */
/* store paths for reuse.
* most of the commands in this presentation will be git commands in a demo repository, so the path to that repo is set
* as implicit (will be the working directory of executed commands) */
val imgs = pwd / "img"
given repoDir: Path = pwd / "demoRepo"
given theme: Theme = Theme.default
/* You can define any variables and use then in your presentation.
* The presentation is pure Scala code, you can use anything that Scala offers . */
val noInteractiveCommands = true
/* image height is defined by the iterm2 image protocol as follows:
* N: N character cells.
* Npx: N pixels.
* N%: N percent of the session's width or height.
* auto: The image's inherent size will be used to determine an appropriate dimension.
*
* The `Image` slide element will default to 100% height and width (preserving aspect ratio). Here the maximum height
* is calculated for slides with the default header (1 row status, 3 rows title font, 3 rows spacing)
*/
val imageHeight = (rows - 7).toDouble //TODO
/* Make sure, the demo repo is not there in the beginning. We'll create it in the presentation */
os.remove.all(repoDir)
/* the basic building block of copret presentations is the various case classes of the `Slide` trait */
val titleSlide =
Group( // `Group` combines several `Slide` objects into one
Clear, // `Clear` clears the screen, so the next slide element starts at the top
Image(
imgs / "git-logo.png",
Some(ImageSize(width = 0.1, height = 1, keepAspect = true)),
), // `Image` currently requires a terminal supporting `imgcat`
Paragraph( // `Paragraph` simply prints its text contents
s"""${figlet("git", "roman").block.blue}
|
|${"Wie man es benutzt und was in .git eigentlich passiert".centered.blue}
|
|${"-- Alexander Gehrke".right(10)}
|""".stripMargin,
),
// Above you see some of the extension methods for strings, `block` for centering multiline text, `centered` for
// single lines, `right(10)` for placing text 10 spaces away from the right border and `blue` for coloring.
// also, `figlet` calls the external figlet command to render ASCII art text with the given font.
)
/* copret templates are simply normal methods that return slides */
def chapter(title: String, subtitle: String): Group =
chapter(title, "", subtitle)
def chapter(title1: String, title2: String, subtitle: String): Group = {
val font =
if ((title1.length max title2.length) < columns / 10) "mono12" else "mono9"
Group(
Clear,
header, // a built in template
Paragraph(
figlet(title1, font).block.green ++ figlet(
title2,
font,
).block.green ++ "\n" ++ subtitle.right(10).green,
),
)
}
val parentSHApattern = raw"parent ([a-f0-9]{40})".r
val treeSHApattern = raw"tree ([a-f0-9]{40})".r
val blobSHApattern = raw"blob ([a-f0-9]{40})".r
/* With `prepare` you can define variables for a slide, that are computed when the presentation reaches the
* slide using it. This is useful for running external commands when a specific slide is reached and using their
* output (if you'd include them in the string interpolation directly, they'd run before starting the presentation).
*
* The code given to `prepare` is run exactly once *per slide using it*. See the usages of `gitHashes` below for
* details. */
case class GitHashes(
commit: String,
tree: String,
blob: String,
parent: String,
subtree: String,
)
val gitHashes = prepare {
val commitSHA = %%%("git", "show-ref").substring(0, 40)
val commit = %%%("git", "cat-file", "-p", "HEAD")
val treeSHA =
treeSHApattern.findFirstMatchIn(commit).map(_.group(1)).getOrElse("")
val parentSHA =
parentSHApattern.findFirstMatchIn(commit).map(_.group(1)).getOrElse("")
val tree = %%%("git", "cat-file", "-p", treeSHA)
val blobSHA =
blobSHApattern.findFirstMatchIn(tree).map(_.group(1)).getOrElse("")
val subtreeSHA =
treeSHApattern.findFirstMatchIn(tree).map(_.group(1)).getOrElse("")
GitHashes(commitSHA, treeSHA, blobSHA, parentSHA, subtreeSHA)
}
def gitStatus = TypedCommand("git", "status")
def gitCatFile(ref: String) = TypedCommand("git", "cat-file", "-p", ref)
def gitLogGraph(cutOff: Int = 0) = {
val shownCmd = "git log --graph --oneline --all"
val execCmd =
shownCmd + " --decorate=short --color=always " + (if (cutOff > 0)
"| head -n -" + cutOff
else "")
val typer = sh(execCmd) display shownCmd
if (cutOff > 0)
Group(typer, "...".text)
else
typer
}
/* A `Presentation` object can include a map of metadata. Which keys are used, depends on your used templates.
* To use them in a slide yourself (or for creating own templates), use the Meta slide class, which can access the
* presentation object and state to generate a slide.
*
* The only keys used in the packaged templates are currently "author" and "title" in the `header` template */
val meta = Map(
"author" -> "Alexander Gehrke",
"title" -> "Git",
)
/* A presentation consists of a `Vector[Slide]`. After each slide in this vector, the presentation pauses and waits for
* input. `Group` is used, to display several slides at once, and `PauseKey` can be used inside a `Group` to wait for
* input (but limited to displaying the rest of the `Group`, normal navigation is only possible between top level
* slides).
*
* Navigation distinguishes between normal and "quick" steps, the latter disables things like pauses or animations. It
* is also possible to jump to a specific slide.
* */
val presentation = Presentation(
Vector(
titleSlide,
slide("Wozu braucht man das?")(
ulist(
"Versionierungssysteme speichern _Änderungsverlauf_ von Dateien",
"Lässt einen Änderungen rückgängig machen",
"Unterstützt einen bei *Zusammenführung* von mehreren Versionen",
"(z.B. stabile und Entwicklungsversion)",
"Synchronisierung zwischen mehreren Rechnern",
"Fehlersuche",
"...",
)
),
chapter("Basics", "Grundlegende Befehle"),
/* `slide` is a template for a `Group` with an optional title, that clears the screen */
slide("Basics")(
"""Git trackt Inhalt von Dateien innerhalb eines Repositories.
|Kann in neuem oder bestehenden Ordner angelegt werden
|""".par,
/* the `---` template displays a yellow line */
---,
/* `Pause` waits for the given number of milliseconds */
Pause(500),
/* `TypedCommand.shell` (here aliased to `sh`) displays a typing animation of that command and then executes it,
* showing its output in the presentation */
sh("git init demoRepo")(using pwd),
/* sometimes it's useful to display something else than is actually executed, e.g. to add comments, or to hide
* options only required because we don't call from an interactive shell (stuff like --color) */
sh("ls -1a --color=always demoRepo")(using pwd) display "ls -1a demoRepo",
/* If you need to run commands that should not display anything in the presentation, use `Silent`.
* Here I use it to prepare the repository, but it could also be used e.g. for playing a sound or opening a video.*/
Silent {
%%%("git", "config", "user.email", "crater2150@example.com")
%%%("git", "config", "user.name", "crater2150")
%%%("git", "config", "color.status", "always")
%%%("git", "config", "color.ui", "always")
},
),
slide("Basics")(
"Noch ist das Repo leer. Ändern wir das:".par,
---,
Pause(500),
TypedCommand.fake("cd demoRepo"),
sh("echo 'Hello, World!' > hello.txt"),
Pause(500),
sh("ls -1a"),
),
slide("Basics")(
"Den Zustand des Repos sehen wir mit `git status`\n".par,
---,
Pause(500),
gitStatus,
),
slide("Basics")(
"Damit Git Dateien trackt, müssen diese mit `git add` hinzugefügt werden.\n".par,
---,
sh("git add hello.txt"),
gitStatus,
),
slide("Basics")(
"""Die gespeicherten Zustände eines Repos nennt man Commit.
|Zu jedem Commit gibt man eine Zusammenfassung der Änderungen an
|""".par,
---,
Pause(500),
sh("git commit --message \"Added first file\""),
PauseKey,
---,
"""Commits werden über ihren SHA1-Hash identifiziert. Dieser wird ggf. abgekürzt.
|Mit `git log` können wir uns die bisherigen Commits ansehen:
|""".par,
---,
sh("git log"),
),
/* here we see `gitHashes`, created with `prepare`, in action:
* As the code is run exactly once per `useIn` call i.e. per using slide, the hashes of the latest commit, tree and
* parent in the demo repo, that `gitHashes` retrieves, are always the ones for the repository state matching the
* slide. The prepared code is run and its result cached, when the slide is first displayed (or skipped over).
*/
gitHashes useIn { sha =>
slide("Was passiert bei einem Commit?")(
s"""Schauen wir uns an, was Git aus dem Commit gemacht hat.
|`git cat-file $$ref` zeigt Inhalt von Git-Objekten. `$$ref` ist Referenz des Objekts, z.B. der Hash.
|Unser Commit hatte den Hash `${sha.commit}`.
|Statt diesem geht auch `HEAD` = aktueller Commit.
|""".par,
---,
gitCatFile("HEAD"),
PauseKey,
---,
s"""Zum Hashen wird vor den Inhalt noch `<Objekttyp> <Länge des Inhalts in bytes\\0` gehängt.
|Hier: `Objekttyp == commit`
|""".par,
)
},
gitHashes useIn { sha =>
slide("Commits, Trees, Blobs")(
s"""Neben den Metadaten ist in unserem Commit noch eine Zeile
| `tree ${sha.tree}`
|
|Trees repräsentieren Verzeichnisse, Tree in einem Commit = Wurzelverzeichnis des Repos
|`$$ref^{tree}` = Baum mit Referenz `$$ref`, oder wenn `$$ref` ein Commit ist, der Tree aus diesem
|""".par,
---,
gitCatFile("HEAD^{tree}"),
PauseKey,
---,
s"""Hier ist noch nicht viel zu sehen, da wir nur eine Datei haben.
|Einträge im Tree haben das Format:
| `<berechtigungen> <blob | tree> <sha1 hash> <dateiname>`
|""".par,
)
},
gitHashes useIn { sha =>
slide("Commits, Trees, Blobs")(
s"""Blobs sind die eigentlichen Dateiinhalte.
|Unsere `hello.txt` hat, wie im Tree zu sehen, den Hash ${sha.blob.substring(0, 8)}
|""".par,
---,
gitCatFile(sha.blob.substring(0, 8)),
)
},
slide("Commits, Trees, Blobs")(
s"""Fügen wir ein paar weitere Dateien und einen Unterordner hinzu
|""".par,
---,
sh("mkdir folder"),
sh("echo 'My other file' > other.txt"),
sh("echo 'File in folder' > folder/file.txt"),
sh("git add other.txt folder"),
sh("git commit -m 'Added more files'"),
),
/* when this slide is reached, our commit hashes have changed. All the previous slides will still display the old ones */
gitHashes useIn { sha =>
slide("Commits, Trees, Blobs")(
s"""Wie sieht der neue Commit aus?
|""".par,
---,
gitCatFile("HEAD"),
---,
PauseKey,
s"""Wir haben eine neue Art von Eintrag:
| `parent ${sha.parent}`
|
|Dieser verweist auf den vorherigen Commit.
|Durch solche Verweise bilden alle Commits einen gerichteten Graphen.
|""".par,
)
},
gitHashes useIn { sha =>
slide("Commits, Trees, Blobs")(
s"""Sehen wir uns den neuen Tree an:
|""".par,
gitCatFile("HEAD^{tree}"),
---,
s"""Eine unserer neuen Dateien ist zu sehen.
|Auch `hello.txt` ist noch da, mit selbem Hash wie vorher (da nicht geändert)
|Die andere neue Datei ist im Verzeichnis `folder`, und daher im `tree` mit dem Hash `${sha.subtree
.substring(0, 8)}` :
|""".par,
gitCatFile(sha.subtree.substring(0, 8)),
)
},
slide("Git als Graph")(
Image(
imgs / "repo-graph.png",
Some(ImageSize(width = 1, height = imageHeight, keepAspect = true)),
),
),
slide("Dateien editieren")(
s"""Auch Änderungen an schon getrackten Dateien müssen mit `git add` zum Repo hinzugefügt werden.
|Erlaubt es, nur Teile der Änderungen zu committen und dadurch commits zu unterteilen.
|Ändern wir ein paar Dateien:
|""".par,
PauseKey,
Group(
interactive("vim", "hello.txt").replaceIf(noInteractiveCommands)(
sh("echo 'New line' >> hello.txt"),
),
interactive("vim", "folder/file.txt").replaceIf(noInteractiveCommands)(
sh("echo 'New line' >> folder/file.txt"),
),
gitStatus,
),
),
slide("Dateien editieren")(
sh("git add folder/file.txt; git status"),
---,
s"""`git status` zeigt, welche Änderungen in den nächsten Commit aufgenommen werden.
|Solche bezeichnet man als "staged" oder "im Stagingbereich".
|Auch zu sehen: Befehle um diese zu modifizieren (`git add` / `git restore --staged`).
|`git restore <file>` (ohne `--staged`) verwirft Änderungen Nicht wieder herstellbar!
|""".par,
),
slide("Dateien editieren")(
sh("git commit -m \"Modified file in folder\""),
sh("git add hello.txt; git commit -m \"Extended greeting\""),
gitStatus,
),
slide("Was bisher geschah")(
ulist(
s"${"git init [dir]".yellow}: neues Repo anlegen",
s"${"git status".yellow}: Aktuellen Zustand zeigen (geänderte, ungetrackte Dateien, Stagingbereich)",
s"${"git add <path>".yellow}: Datei zu Repo hinzufügen / Änderungen in Datei für Commit markieren / \"stagen\"",
s"${"git restore --staged <path>".yellow}: Änderungen aus Staging wieder herausnehmen",
s"${"git restore <path>".yellow}: Änderungen in Datei verwerfen (zurück zu Dateizustand aus letzten Commit)",
s"${"git commit [-m message]".yellow}: Neuen Commit mit Änderung im Stagingbereich erstellen",
s"${"git log".yellow}: Bisherige Commits anzeigen",
s"(${"git cat-file".yellow}: Git-Objekte im internen Format anzeigen. Braucht man im Alltag nicht)",
)
),
chapter("Branches", "Grundlegende Befehle"),
slide("Branches")(
"Branches sind mehrere parallele Entwicklungszweige. Wozu Branches?".par,
ulist(
"Hauptbranch (master, main, etc.) für stabile Version nutzen",
"neue Branches pro zusätzlichem Feature erstellen",
"Arbeit an mehreren Branches unabhängig voneinander möglich",
"Anzeigen aller Änderungen zwischen Branches möglich",
"Springen zwischen Zustand verschiedener Branches",
),
"Verschiedene Workflows möglich, je nach Projekt unterschiedlich.".par,
"Branches sind in Git \"billig\":".par,
ulist(
"Ein Branch ist ein Pointer auf einen Commit",
"Normalerweise ist ein Branch als aktueller Branch ausgewählt",
"Neuer Commit => aktueller Branch zeigt nun auf diesen",
"Neue Branches erstellen = neue Datei mit Hash anlegen",
"Branches können auch komplett lokal bleiben (im Gegensatz zu SVN)",
),
),
slide("Branches")(
sh("git branch"),
---,
prepare { %%%("git", "branch", "--show-current").trim } useIn { branch =>
s"Aktuell sind wir auf dem Branch `$branch` und es gibt keine weiteren Branches\n".par
},
PauseKey,
s"""Neuen Branch anlegen mit `git branch <name>`
|Alternativ: Branch anlegen und direkt auf diesen wechseln mit `git switch -c <name>`
|""".par,
---,
sh("git switch -c feature/foo"),
sh("git branch"),
),
slide("Branches")(
"Machen wir ein paar Änderungen und committen sie:\n".par,
---,
sh(
"echo 'a new line' >> hello.txt",
) display "echo 'a new line' >> hello.txt # appends to hello.txt",
sh("git add hello.txt"),
sh("git commit -m \"Added line to hello\""),
---,
"Da der Branch `feature/foo` aktiv war, zeigt dieser auf den neuen Commit. `master` wird nicht geändert:".code.text,
PauseKey,
sh(
"git log --graph --oneline --all --decorate=short --color=always",
) display "git log --graph --oneline --all",
),
slide("Branches")(
"Jetzt wechseln wir zurück zu `master`:\n".par,
---,
sh("git switch master"),
sh("echo 'Also a new line' >> other.txt"),
sh("git add other.txt"),
sh("git commit -m \"Added line to other\""),
gitLogGraph(),
),
slide("Branches")(
"Im Prinzip haben wir einfach zwei Commits mit dem selben `parent` hinzugefügt:".code.text,
---,
gitCatFile("master"),
gitCatFile("feature/foo"),
),
slide("Merges")(
"""Im Normalfall möchte man mehrere Branches irgendwann wieder zusammenführen.
|Dazu gibt es den Befehl `merge`:""".par,
---,
sh("git merge feature/foo"),
gitLogGraph(2),
),
slide("Merge Commits")(
"Um zwei divergierte Branches zusammenzuführen, gibt es Merge Commits mit mehreren `parent` Einträgen:".code.text,
---,
gitCatFile("master"),
),
slide("Merge Konflikte")(
"Was passiert, wenn wir nicht kompatible Änderungen haben? Noch mal vom Zustand vor dem Merge aus, auf `master`:".code.text,
Silent {
%%%("git", "update-ref", "refs/heads/master", "feature/foo~")
},
---,
sh("echo 'a different line' >> hello.txt"),
sh("git add hello.txt"),
sh("git commit -m \"Added different line to hello\""),
gitLogGraph(),
),
slide("Der Object Store")(
"""Git speichert Objekte unter `.git/objects/<erste zwei Stellen vom Hash>/<Rest vom Hash>`
|Objekte sind komprimiert.
|""".par,
sh("tree -C .git/objects/ | head -n 11") display "tree .git/objects/",
s"...".par,
),
),
meta = meta,
)
/* When starting the presentation, you can pass a custom keymap to use.
* The `runForeground` action lets you run any command, interrupting the presentation until it exits.
* Here I bind the 'i' key to open tmux in the demo repo, for having interactive access, so I can call additional git
* commands for questions */
presentation.start(using
Keymap.default ++ Map(
Key('i') -> SlideAction.runForeground("tmux"),
),
)
/* vim:set tw=120: */

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View file

@ -0,0 +1,7 @@
- Versionierungssysteme speichern _Änderungsverlauf_ von Dateien
- Lässt einen Änderungen rückgängig machen
- Unterstützt einen bei *Zusammenführung* von mehreren Versionen
(z.B. stabile und Entwicklungsversion)
- Synchronisierung zwischen mehreren Rechnern
- Fehlersuche
- ...

View file

@ -0,0 +1,18 @@
Branches sind mehrere parallele Entwicklungszweige. Wozu Branches?
- Hauptbranch (master, main, etc.) für stabile Version nutzen
- neue Branches pro zusätzlichem Feature erstellen
- Arbeit an mehreren Branches unabhängig voneinander möglich
- Anzeigen aller Änderungen zwischen Branches möglich
- Springen zwischen Zustand verschiedener Branches
Verschiedene Workflows möglich, je nach Projekt unterschiedlich.
Branches sind in Git "billig":
- Ein Branch ist ein Pointer auf einen Commit
- Normalerweise ist ein Branch als aktueller Branch ausgewählt
- Neuer Commit => aktueller Branch zeigt nun auf diesen
- Neue Branches erstellen = neue Datei mit Hash anlegen
- Branches können auch komplett lokal bleiben (im Gegensatz zu SVN)

View file

@ -0,0 +1,16 @@
- `git init [dir]`:
neues Repo anlegen
- `git status`:
Aktuellen Zustand zeigen (geänderte, ungetrackte Dateien, Stagingbereich)
- `git add <path>`:
Datei zu Repo hinzufügen / Änderungen in Datei für Commit markieren / "stagen"
- `git restore --staged <path>`:
Änderungen aus Staging wieder herausnehmen
- `git restore <path>`:
Änderungen in Datei verwerfen (zurück zu Dateizustand aus letzten Commit)
- `git commit [-m message]`:
Neuen Commit mit Änderung im Stagingbereich erstellen
- `git log`:
Bisherige Commits anzeigen
- (`git cat-file`: Git-Objekte im internen Format anzeigen. Braucht man im Alltag nicht)

View file

@ -1,374 +0,0 @@
#!/usr/bin/env amm
import $ivy.`de.qwertyuiop::copret:0.0.1`
import de.qwertyuiop.copret._
import de.qwertyuiop.copret.syntax._
import TypedCommand.{interactive, shell => sh}, Format.figlet
import ammonite.ops._
/* Configuration */
/* store paths for reuse.
* most of the commands in this presentation will be git commands in a demo repository, so the path to that repo is set
* as implicit (will be the working directory of executed commands) */
val imgs = pwd/"img"
implicit val repoDir = pwd/"demoRepo"
implicit val theme = Theme.default
/* You can define any variables and use then in your presentation.
* The presentation is pure Scala code, you can use anything that Scala offers . */
val debug = true
/* image height is defined by the iterm2 image protocol as follows:
* N: N character cells.
* Npx: N pixels.
* N%: N percent of the session's width or height.
* auto: The image's inherent size will be used to determine an appropriate dimension.
*
* The `Image` slide element will default to 100% height and width (preserving aspect ratio). Here the maximum height
* is calculated for slides with the default header (1 row status, 3 rows title font, 3 rows spacing)
*/
val imageHeight = (rows - 7).toString
/* Make sure, the demo repo is not there in the beginning. We'll create it in the presentation */
rm! repoDir
/* the basic building block of copret presentations is the various case classes of the `Slide` trait */
val titleSlide =
Group( // `Group` combines several `Slide` objects into one
Clear, // `Clear` clears the screen, so the next slide element starts at the top
Image(imgs/"git-logo.png", width = "10%"), // `Image` currently requires a terminal supporting `imgcat`
Paragraph( // `Paragraph` simply prints its text contents
s"""${figlet("git", "roman").block.blue}
|
|${"Wie man es benutzt und was in .git eigentlich passiert".centered.blue}
|
|${"-- Alexander Gehrke".right(10)}
|""".stripMargin)
// Above you see some of the extension methods for strings, `block` for centering multiline text, `centered` for
// single lines, `right(10)` for placing text 10 spaces away from the right border and `blue` for coloring.
// also, `figlet` calls the external figlet command to render ASCII art text with the given font.
)
/* copret templates are simply normal methods that return slides */
def chapter(title: String, subtitle: String): Group = chapter(title, "", subtitle)
def chapter(title1: String, title2: String, subtitle: String): Group = {
val font = if((title1.length max title2.length) < columns / 10) "mono12" else "mono9"
Group(Clear,
header, // a built in template
Paragraph(
figlet(title1, font).block.green ++ figlet(title2, font).block.green + "\n" + subtitle.right(10).green
)
)
}
val parentSHApattern = raw"parent ([a-f0-9]{40})".r
val treeSHApattern = raw"tree ([a-f0-9]{40})".r
val blobSHApattern = raw"blob ([a-f0-9]{40})".r
/* With `prepare` you can define variables for a slide, that are computed when the presentation reaches the
* slide using it. This is useful for running external commands when a specific slide is reached and using their
* output (if you'd include them in the string interpolation directly, they'd run before starting the presentation).
*
* The code given to `prepare` is run exactly once *per slide using it*. See the usages of `gitHashes` below for
* details. */
case class GitHashes(commit: String, tree: String, blob: String, parent: String, subtree: String)
val gitHashes = prepare {
val commitSHA = %%%("git", "show-ref").substring(0,40)
val commit = %%%("git", "cat-file", "-p", "HEAD")
val treeSHA = treeSHApattern.findFirstMatchIn(commit).map(_.group(1)).getOrElse("")
val parentSHA = parentSHApattern.findFirstMatchIn(commit).map(_.group(1)).getOrElse("")
val tree = %%%("git", "cat-file", "-p", treeSHA)
val blobSHA = blobSHApattern.findFirstMatchIn(tree).map(_.group(1)).getOrElse("")
val subtreeSHA = treeSHApattern.findFirstMatchIn(tree).map(_.group(1)).getOrElse("")
GitHashes(commitSHA, treeSHA, blobSHA, parentSHA, subtreeSHA)
}
def gitStatus = TypedCommand("git", "status")
def gitCatFile(ref: String) = TypedCommand("git", "cat-file", "-p", ref)
def gitLogGraph(cutOff: Int = 0) = {
val shownCmd = "git log --graph --oneline --all"
val execCmd = shownCmd + " --decorate=short --color=always " + (if(cutOff > 0) "| head -n -" + cutOff else "")
val typer = sh(execCmd) display shownCmd
if(cutOff > 0)
Group(typer, "...".text)
else
typer
}
/* A `Presentation` object can include a map of metadata. Which keys are used, depends on your used templates.
* To use them in a slide yourself (or for creating own templates), use the Meta slide class, which can access the
* presentation object and state to generate a slide.
*
* The only keys used in the packaged templates are currently "author" and "title" in the `header` template */
val meta = Map(
"author" -> "Alexander Gehrke",
"title" -> "Git"
)
/* A presentation consists of a `Vector[Slide]`. After each slide in this vector, the presentation pauses and waits for
* input. `Group` is used, to display several slides at once, and `PauseKey` can be used inside a `Group` to wait for
* input (but limited to displaying the rest of the `Group`, normal navigation is only possible between top level
* slides).
*
* Navigation distinguishes between normal and "quick" steps, the latter disables things like pauses or animations. It
* is also possible to jump to a specific slide.
* */
val presentation = Presentation(Vector(
titleSlide,
/* for simple slides, the `markdown` template and `IncludeMarkdown` Slide allow including plain markdown files.
* (currently requires mdcat) */
markdown("Wozu braucht man das?", pwd/"slides"/"01wozu.md"),
chapter("Basics", "Grundlegende Befehle"),
/* `slide` is a template for a `Group` with an optional title, that clears the screen */
slide("Basics")(
"""Git trackt Inhalt von Dateien innerhalb eines Repositories.
|Kann in neuem oder bestehenden Ordner angelegt werden
|""".par,
/* the `---` template displays a yellow line */
---,
/* `Pause` waits for the given number of milliseconds */
Pause(500),
/* `TypedCommand.shell` (here aliased to `sh`) displays a typing animation of that command and then executes it,
* showing its output in the presentation */
sh("git init demoRepo")(pwd),
/* sometimes it's useful to display something else than is actually executed, e.g. to add comments, or to hide
* options only required because we don't call from an interactive shell (stuff like --color) */
sh("ls -1a --color=always demoRepo")(pwd) display "ls -1a demoRepo",
/* If you need to run commands that should not display anything in the presentation, use `Silent`.
* Here I use it to prepare the repository, but it could also be used e.g. for playing a sound or opening a video.*/
Silent {
%%%("git", "config", "user.email", "crater2150@example.com")
%%%("git", "config", "user.name", "crater2150")
%%%("git", "config", "color.status", "always")
%%%("git", "config", "color.ui", "always")
}
),
slide("Basics")(
"Noch ist das Repo leer. Ändern wir das:".par,
---,
Pause(500),
TypedCommand.fake("cd demoRepo"),
sh("echo 'Hello, World!' > hello.txt"),
Pause(500),
sh("ls -1a")
),
slide("Basics")(
"Den Zustand des Repos sehen wir mit `git status`\n".par,
---,
Pause(500),
gitStatus,
),
slide("Basics")(
"Damit Git Dateien trackt, müssen diese mit `git add` hinzugefügt werden.\n".par,
---,
sh("git add hello.txt"),
gitStatus,
),
slide("Basics")(
"""Die gespeicherten Zustände eines Repos nennt man Commit.
|Zu jedem Commit gibt man eine Zusammenfassung der Änderungen an
|""".par,
---,
Pause(500),
sh("git commit --message \"Added first file\""),
PauseKey,
---,
"""Commits werden über ihren SHA1-Hash identifiziert. Dieser wird ggf. abgekürzt.
|Mit `git log` können wir uns die bisherigen Commits ansehen:
|""".par,
---,
sh("git log")
),
/* here we see `gitHashes`, created with `prepare`, in action:
* As the code is run exactly once per `useIn` call i.e. per using slide, the hashes of the latest commit, tree and
* parent in the demo repo, that `gitHashes` retrieves, are always the ones for the repository state matching the
* slide. The prepared code is run and its result cached, when the slide is first displayed (or skipped over).
*/
gitHashes useIn { sha => slide("Was passiert bei einem Commit?")(
s"""Schauen wir uns an, was Git aus dem Commit gemacht hat.
|`git cat-file $$ref` zeigt Inhalt von Git-Objekten. `$$ref` ist Referenz des Objekts, z.B. der Hash.
|Unser Commit hatte den Hash `${sha.commit}`.
|Statt diesem geht auch `HEAD` = aktueller Commit.
|""".par,
---,
gitCatFile("HEAD"),
PauseKey,
---,
s"""Zum Hashen wird vor den Inhalt noch `<Objekttyp> <Länge des Inhalts in bytes\\0` gehängt.
|Hier: `Objekttyp == commit`
|""".par,
)},
gitHashes useIn { sha => slide("Commits, Trees, Blobs")(
s"""Neben den Metadaten ist in unserem Commit noch eine Zeile
| `tree ${sha.tree}`
|
|Trees repräsentieren Verzeichnisse, Tree in einem Commit = Wurzelverzeichnis des Repos
|`$$ref^{tree}` = Baum mit Referenz `$$ref`, oder wenn `$$ref` ein Commit ist, der Tree aus diesem
|""".par,
---,
gitCatFile("HEAD^{tree}"),
PauseKey,
---,
s"""Hier ist noch nicht viel zu sehen, da wir nur eine Datei haben.
|Einträge im Tree haben das Format:
| `<berechtigungen> <blob | tree> <sha1 hash> <dateiname>`
|""".par
)},
gitHashes useIn { sha => slide("Commits, Trees, Blobs")(
s"""Blobs sind die eigentlichen Dateiinhalte.
|Unsere `hello.txt` hat, wie im Tree zu sehen, den Hash ${sha.blob.substring(0,8)}
|""".par,
---,
gitCatFile(sha.blob.substring(0,8)),
)},
slide("Commits, Trees, Blobs")(
s"""Fügen wir ein paar weitere Dateien und einen Unterordner hinzu
|""".par,
---,
sh("mkdir folder"),
sh("echo 'My other file' > other.txt"),
sh("echo 'File in folder' > folder/file.txt"),
sh("git add other.txt folder"),
sh("git commit -m 'Added more files'")
),
/* when this slide is reached, our commit hashes have changed. All the previous slides will still display the old ones */
gitHashes useIn { sha => slide("Commits, Trees, Blobs")(
s"""Wie sieht der neue Commit aus?
|""".par,
---,
gitCatFile("HEAD"),
---,
PauseKey,
s"""Wir haben eine neue Art von Eintrag:
| `parent ${sha.parent}`
|
|Dieser verweist auf den vorherigen Commit.
|Durch solche Verweise bilden alle Commits einen gerichteten Graphen.
|""".par
)},
gitHashes useIn { sha => slide("Commits, Trees, Blobs")(
s"""Sehen wir uns den neuen Tree an:
|""".par,
gitCatFile("HEAD^{tree}"),
---,
s"""Eine unserer neuen Dateien ist zu sehen.
|Auch `hello.txt` ist noch da, mit selbem Hash wie vorher (da nicht geändert)
|Die andere neue Datei ist im Verzeichnis `folder`, und daher im `tree` mit dem Hash `${sha.subtree.substring(0,8)}` :
|""".par,
gitCatFile(sha.subtree.substring(0,8)),
)},
slide("Git als Graph")(Image(imgs/"repo-graph.png", height=imageHeight)),
slide("Dateien editieren")(
s"""Auch Änderungen an schon getrackten Dateien müssen mit `git add` zum Repo hinzugefügt werden.
|Erlaubt es, nur Teile der Änderungen zu committen und dadurch commits zu unterteilen.
|Ändern wir ein paar Dateien:
|""".par,
PauseKey,
Group(
interactive("vim", "hello.txt").replaceIf(debug)(sh("echo 'New line' >> hello.txt")),
interactive("vim", "folder/file.txt").replaceIf(debug)(sh("echo 'New line' >> folder/file.txt")),
gitStatus,
),
),
slide("Dateien editieren")(
sh("git add folder/file.txt; git status"),
---,
s"""`git status` zeigt, welche Änderungen in den nächsten Commit aufgenommen werden.
|Solche bezeichnet man als "staged" oder "im Stagingbereich".
|Auch zu sehen: Befehle um diese zu modifizieren (`git add` / `git restore --staged`).
|`git restore <file>` (ohne `--staged`) verwirft Änderungen Nicht wieder herstellbar!
|""".par,
),
slide("Dateien editieren")(
sh("git commit -m \"Modified file in folder\""),
sh("git add hello.txt; git commit -m \"Extended greeting\""),
gitStatus,
),
markdown("Was bisher geschah", pwd/"slides"/"02summary.md"),
chapter("Branches", "Grundlegende Befehle"),
slide("\n".par, IncludeMarkdown(pwd/"slides"/"02branches.md")),
slide("Branches")(
sh("git branch"),
---,
prepare { %%%("git", "branch", "--show-current").trim } useIn { branch =>
s"Aktuell sind wir auf dem Branch `$branch` und es gibt keine weiteren Branches\n".par
},
PauseKey,
s"""Neuen Branch anlegen mit `git branch <name>`
|Alternativ: Branch anlegen und direkt auf diesen wechseln mit `git switch -c <name>`
|""".par,
---,
sh("git switch -c feature/foo"),
sh("git branch"),
),
slide("Branches")(
"Machen wir ein paar Änderungen und committen sie:\n".par,
---,
sh("echo 'a new line' >> hello.txt") display "echo 'a new line' >> hello.txt # appends to hello.txt",
sh("git add hello.txt"),
sh("git commit -m \"Added line to hello\""),
---,
"Da der Branch `feature/foo` aktiv war, zeigt dieser auf den neuen Commit. `master` wird nicht geändert:".code.text,
PauseKey,
sh("git log --graph --oneline --all --decorate=short --color=always") display "git log --graph --oneline --all",
),
slide("Branches")(
"Jetzt wechseln wir zurück zu `master`:\n".par,
---,
sh("git switch master"),
sh("echo 'Also a new line' >> other.txt"),
sh("git add other.txt"),
sh("git commit -m \"Added line to other\""),
gitLogGraph(),
),
slide("Branches")(
"Im Prinzip haben wir einfach zwei Commits mit dem selben `parent` hinzugefügt:".code.text,
---,
gitCatFile("master"),
gitCatFile("feature/foo"),
),
slide("Merges")(
"""Im Normalfall möchte man mehrere Branches irgendwann wieder zusammenführen.
|Dazu gibt es den Befehl `merge`:""".par,
---,
sh("git merge feature/foo"),
gitLogGraph(2),
),
slide("Merge Commits")(
"Um zwei divergierte Branches zusammenzuführen, gibt es Merge Commits mit mehreren `parent` Einträgen:".code.text,
---,
gitCatFile("master"),
),
slide("Merge Konflikte")(
"Was passiert, wenn wir nicht kompatible Änderungen haben? Noch mal vom Zustand vor dem Merge aus, auf `master`:".code.text,
Silent {
%%%("git", "update-ref", "refs/heads/master", "feature/foo~")
},
---,
sh("echo 'a different line' >> hello.txt"),
sh("git add hello.txt"),
sh("git commit -m \"Added different line to hello\""),
gitLogGraph(),
),
slide("Der Object Store")(
"""Git speichert Objekte unter `.git/objects/<erste zwei Stellen vom Hash>/<Rest vom Hash>`
|Objekte sind komprimiert.
|""".par,
sh("tree -C .git/objects/ | head -n 11") display "tree .git/objects/",
s"...".par,
),
), meta=meta)
/* When starting the presentation, you can pass a custom keymap to use.
* The `runForeground` action lets you run any command, interrupting the presentation until it exits.
* Here I bind the 'i' key to open tmux in the demo repo, for having interactive access, so I can call additional git
* commands for questions */
presentation.start(Keymap.default ++ Map(
Key('i') -> SlideAction.runForeground("tmux"),
))
/* vim:set tw=120: */

14
examples/query.sc Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env -S scala-cli shebang
//>using dep de.qwertyuiop::copret:0.0.2
import de.qwertyuiop.copret.*
import Terminal.*
enterRawMode()
val hasGraphics =
queryTerm(s"${apc}Gi=${KittyGraphicsProtocol.MaxID},s=1,v=1,a=q,t=d,f=24;AAAA${st}${csi}c")
.contains(s"${apc}Gi=${KittyGraphicsProtocol.MaxID}")
val size = queryTerm(s"${csi}14t")
println(s"Has graphics: $hasGraphics")

View file

@ -0,0 +1,36 @@
#!/bin/zsh
zparseopts -D -E p:=padding -padding:=padding
lines=("$@")
if [[ -z $lines ]]; then
lines=()
while read line; do
lines+=($line)
done
fi
width=0
for line in $lines; do
if [[ ${#line} -gt $width ]]; then
width=${#line}
fi
done
lpad_width=$((width + ${padding[2]:-0}))
rpad_width=$((lpad_width * 2))
pagga=( $(
for i in {1..$#lines}; do
line=$lines[$i]
padded="${(r:$(( rpad_width - ${#line})):: :)${(l:${lpad_width}:: :)line}}"
spaces=$(echo $line | tr -cd ' ' | wc -c)
if [[ $spaces -gt 0 ]]; then
padded="$padded${(l:spaces:: :)}"
fi
echo $padded
done | figlet -fpagga -t
) )
left_space=$(( (COLUMNS - ${#pagga[1]}) / 2 ))
for line in ${pagga[*]}; do
echo "${(l:left_space:: :)}$line"
done

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

View file

@ -0,0 +1,529 @@
#!/usr/bin/env -S scala-cli shebang
//>using scala 3.3
//>using dep de.qwertyuiop::copret:0.0.2
import de.qwertyuiop.copret.Terminal.*
import de.qwertyuiop.copret.*
import de.qwertyuiop.copret.syntax.*
import TypedCommand.{interactive, shell => sh}
import os.Path
given presentationRoot: Path = os.Path("/home/crater2150/org/opencolloq/terminal/termescape/")
val imgs = presentationRoot / "img"
given theme: Theme = Theme.default
def figlet(str: String, font: String): String = String(
os.proc("figlet", "-t", "-f", font, str).call().out.bytes,
)
def lolcat(str: String): String = String(
os.proc("lolcat", "-b", "-f", "-h", "0.7", "-r").call(stdin = str).out.bytes,
)
def lolcatCustom(str: String, opts: String*): String = String(
os.proc("lolcat", "-f", opts).call(stdin = str).out.bytes,
)
def lollet(str: String, font: String): String = lolcat(figlet(str, font))
// debug mode
val noInteractiveCommands = false
val titleSlide =
Group(
Clear,
TypedCommand.interactiveUntyped("./title.zsh").disable("", noInteractiveCommands),
Clear,
header,
Paragraph(
lollet("Terminal", "mono12").block ++ lollet("basics", "mono12").block
++ "\n" ++ "Was tut so eine Terminalanwendung eigentlich?".right(10),
),
)
/* copret templates are simply normal methods that return slides */
def chapter(title: String, subtitle: String): Group =
chapter(title, "", subtitle)
def chapter(title1: String, title2: String, subtitle: String): Group = {
val font =
if ((title1.length max title2.length) < columns / 10) "mono12" else "mono9"
Group(
Clear,
header, // a built in template
Paragraph(
lollet(title1, font).block ++ lollet(title2, font).block ++ "\n" ++ subtitle.right(10),
),
)
}
val meta = Map(
"author" -> "Alexander Gehrke",
"title" -> "Terminals",
)
def sgr(code: String): String = s"\u001b[${code}m"
def sgr(code: Int*): String = sgr(code.mkString(":"))
val reset = sgr(0)
extension (s: String)
def style(code: String): String = sgr(code) + s + reset
def style(code: Int*): String = sgr(code*) + s + reset
def italic = s.style(3)
def bold = s.style(1)
def link(id: Int, uri: String) = s"\u001b]8;id=$id;$uri\u001b\\" + s + "\u001b]8;;\u001b\\"
def emph = sgr(3) + lolcat(s) + reset
def strong = sgr(1) + lolcat(s) + reset
def underline(style: Int = 1, color: Int = 7) =
sgr("4:" + style) + sgr(58, 5, color) + s + reset
val rainbow = Vector(
"\u001b[38:5:196m",
"\u001b[38:5:202m",
"\u001b[38:5:208m",
"\u001b[38:5:214m",
"\u001b[38:5:220m",
"\u001b[38:5:226m",
"\u001b[38:5:190m",
"\u001b[38:5:154m",
"\u001b[38:5:118m",
"\u001b[38:5:82m",
"\u001b[38:5:46m",
"\u001b[38:5:47m",
"\u001b[38:5:48m",
"\u001b[38:5:49m",
"\u001b[38:5:50m",
"\u001b[38:5:51m",
"\u001b[38:5:45m",
"\u001b[38:5:39m",
"\u001b[38:5:33m",
)
def list(bullet: (Int, Int) => String, continued: (Int, Int) => String)(items: String*)(using Theme): Paragraph =
val randbow = util.Random.shuffle(rainbow)
items.zipWithIndex
.map((str, index) => {
def formatFirst(first: String) =
s" ${randbow(index % randbow.length)}${bullet(index, items.size)}\u001b[0m $first"
str.split("\n").toList match
case first :: Nil => formatFirst(first)
case first :: cont =>
formatFirst(first)
+ cont.map(line => s" ${continued(index, items.size)}${line}").mkString("\n", "\n", "")
case Nil => ""
})
.mkString("\n")
.par
def ulist(items: String*)(using Theme) = list(bullet = (_, _) => "", continued = (_, _) => " ")(items*)
def digits(i: Int): Int = math.ceil(math.log10(i)).toInt
def olist(items: String*)(using Theme) =
list(
bullet = (i, max) => (" " * (digits(max + 1) - digits(i + 1) - 1)) + s"${i + 1}.",
continued = (i, max) => " " * digits(max + 1),
)(items*)
val slides = Vector(
titleSlide,
slide("Terminal IO")(
"Eine Anwendung in einem Terminal kommuniziert (fast) nur via Standard-IO mit diesem:".par,
ulist(
"stdin, für Benutzereingaben und Nachrichten vom Terminal",
"stdout, für Textausgabe und Nachrichten an das Terminal",
"stderr, wird wie stdout behandelt",
),
s"=> Alle Steuersignale vom Programm ans Terminal oder umgekehrt sind ${"inband".emph}".par,
),
slide("Steuersignale")(
s"""|Wie unterscheiden Terminal und Programm normalen IO von Steuersignalen?
|Durch ${"Steuerzeichen".emph}, aufgeteilt in zwei Bereiche:""".par,
s"""|${"C0".strong}: alle Zeichen im Bereich von 0x00 bis 0x1F
|Größtenteils veraltet. Noch relevant sind u.a.:""".par,
ulist(
"Whitespaces (\\n, \\r, \\t, ...) und Backspace (0x08)",
"Bell (\\a), führt bei den meisten Desktops zu blinkendem Icon o.ä. (Urgent Flag)",
"XON/XOFF (0x11/0x13), schaltet Ausgabe an/aus (Flow Control)",
"Escape (\\e, 0x1B), das wichtigste für diesen Talk, startet Escapesequenzen",
),
s"""|${"C1".strong}: Zeichen im Bereich von 0x80 bis 0x9F
|Wegen Kollisionen mit Unicode inkompatibel.
|Heute üblicherweise durch Escapesequenzen ersetzt""".par,
),
chapter("Escape-", "sequenzen", "die verschiedenen Typen"),
slide("CSI")(
PauseKey,
Image(imgs / "csi.png"),
),
slide("CSI")(
"""|Control Sequence Introducer (CSI) ist der vermutlich häufigste Typ.
|Wird benutzt für:""".par,
ulist(
"Cursor-Steuerung",
"Löschen von Zeichen oder Zeilen",
s"${lolcat("Farben")} ${"und".italic} ${"andere".style(42)} ${"Formatierung".underline(style = 3, color = 1)}",
),
"|CSI-Sequenzen haben folgendes Format:\n".par,
s"${"ESC [".style(94)} ${"<ext>".style(96)} ${"arg1 : arg2 : ... : argn".style(93)} ${"<cmd>".style(92)}".block.text,
s"""|
|${"✦ ESC [".style(94)} ist der Teil, der CSI genannt wird
|${"✦ <cmd>".style(92)} gibt an, was gemacht werden soll. Immer ein Zeichen zwischen 0x40 (@) und 0x7F (~)
|${"✦ <ext>".style(92)} darf aus dem ASCII-Bereich 0x20 - 0x3F außer Ziffern, ; und : sein
| Gibt es, weil man mehr Befehle wollte, als für <cmd> Zeichen erlaubt sind.
|${"✦ arg1 : arg2 : ... : argn".style(93)} sind numerische Parameter.
| Statt : wird teilweise ; benutzt (ISO-Standard vs. Realität).
|\n""".stripMargin.block.text,
s"""\nBeispiel: ${"<ESC>[31m".style(31)} schaltet auf rote Schrift um""".par,
),
slide("OSC")(
"""|Operating System Commands (OSC) haben verschiedenste Anwendungszwecke,
|z.B. Setzen des Fenstertitels, Zugriff aufs Clipboard, etc.
|Im Gegensatz zu CSI Escapes können sie die meisten Zeichen enthalten
| => komplexere Befehle möglich.""".par,
s"${"ESC ]".style(94)} ${"<Zahl>".style(92)} ; ${"args...".style(93)} ${"ST".style(96)}".block.text,
s"""|
|${"✦ ESC ]".style(94)} startet ein OSC
|${"✦ <Zahl>".style(92)} gibt an, was gemacht werden soll, ein- bis vierstellige Zahl
|${"✦ args...".style(93)} sind je nach Befehl die Argumente. Hier ist alles außer ST erlaubt, oft Base64.
|${"✦ ST".style(96)} ist das String Terminator Zeichen. Entweder BEL (\\a) oder ESC \\
|""".stripMargin.block.text,
s"""|
|Beispiel: ${"<ESC>]52;c;SGVsbG9Xb3JsZA==<ESC>\\".style(38, 5, 145)}
| OSC52 = Clipboard, "c" gibt an welches Clipboard
| base64-codierter Text entspricht "HelloWorld"
""".par,
),
slide("APC")(
"Application Program Commands (APC) sind ähnlich zu OSC, aber größtenteils\nspezifisch für einzelne Terminals".par,
ulist(
s"fangen mit ${"ESC _".style(3, 94)} an statt mit ${"ESC ]".style(3, 94)}",
s"Format bis zum ${"ST".style(3, 96)} ist uneinheitlich",
"Benutzt z.B. bei screen und tmux zum Setzen von Statusleisten",
"Beispiel für mittlerweile verbreitetere Anwendung: Kitty Graphics Protocol",
),
),
chapter("Farben und ", "Formatierung", "Escapes für dargestellte Zellen"),
slide("Terminaloutput")(
"""|"Bevor wir uns Formatierung ansehen, erst ein kurzer Überblick darüber,
|wie die Ausgabe eines Terminals funktioniert:""".par,
ulist(
"State Machine, die Cursorposition und aktuelle Formatierung hält",
"Ausgabe aufgeteilt in Zellen, normalerweise ein Zeichen pro Zelle",
"Escapes in Ausgabe können Formatierung und Cursorposition ändern",
"druckbare Zeichen wird an Cursorposition geschrieben\nCursor wandert weiter, Formatierung bleibt",
),
"""|=> Formatierung gilt für die Zelle, in der der Cursor steht
| und alle folgenden Zellen, bis sie geändert wird""".par,
),
slide("SGR Codes")(
"""|Die meisten Formatierungen sind SGR-Sequenzen (Select Graphic Rendition).
|Das sind CSI-Sequenzen mit "m" als Endzeichen.
|Die vermutlich wichtigste ist die zum Zurücksetzen auf Default-Formatierung:""".par,
s"${"ESC [".style(94)} ${"0".style(92)} ${"m".style(96)}".block.text,
s"""|
|Alle SGR-Codes beginnen mit dem ${"CSI".style(94)} und enden mit einem "${"m".style(96)}".
|Die ${"0".style(92)} in der Mitte gibt den eigentlichen Befehl.
|Hier können je nach Code weitere Parameter folgen""".par,
),
slide("Farben")(
"""|Farben sind die wohl am häufigsten genutzte Formatierung.
|Es gibt verschiedene Arten von Farbcodes in Terminals:""".par,
ulist(
"Palettenfarben mit 8 oder 16 Farben",
"256-Farbenpalette, inzwischen auch fast überall unterstützt",
"Truecolor, 24-Bit-Farben, die in modernen Terminals unterstützt werden",
),
s"""|Beispiel: Farbverlauf von #55ff22 ${"██".style("38:2::85:255:34")} nach #0000ff ${"██".style(
"38:2::0:0:255",
)}:""".par,
("256 Farbpalette: " + lolcatCustom("".repeat(60), "-h", "0.18", "-g", "55ff22:0000ff")).par,
("24-Bit-Farben: " + lolcatCustom("".repeat(60), "-h", "0.40", "-g", "55ff22:0000ff", "-b")).par,
),
slide("Die Grundpalette")(
"""|Wir fangen an mit den 8/16 Grundfarben. Die Codes zum Setzen bestehen aus mehreren Ziffern.
|Der Anfang gibt an, welche Farbe gesetzt werden soll:""".par,
ulist(
"Beginn mit 3: Vordergrundfarbe",
"Beginn mit 4: Hintergrundfarbe",
"Beginn mit 9: Helle Vordergrundfarbe",
"Beginn mit 10: Helle Hintergrundfarbe",
),
"Anschließend folgt eine Ziffer für die Farbe (tatsächliche Farbe einstellungsabhängig):".par,
s"""|0 Schwarz ${"abc".style("30")} ${"abc".style("40")} hell: ${"abc".style("90")} ${"abc".style("100")}
|1 Rot ${"abc".style("31")} ${"abc".style("41")} hell: ${"abc".style("91")} ${"abc".style("101")}
|2 Grün ${"abc".style("32")} ${"abc".style("42")} hell: ${"abc".style("92")} ${"abc".style("102")}
|3 Gelb ${"abc".style("33")} ${"abc".style("43")} hell: ${"abc".style("93")} ${"abc".style("103")}
|4 Blau ${"abc".style("34")} ${"abc".style("44")} hell: ${"abc".style("94")} ${"abc".style("104")}
|5 Magenta ${"abc".style("35")} ${"abc".style("45")} hell: ${"abc".style("95")} ${"abc".style("105")}
|6 Cyan ${"abc".style("36")} ${"abc".style("46")} hell: ${"abc".style("96")} ${"abc".style("106")}
|7 Weiß ${"abc".style("37")} ${"abc".style("47")} hell: ${"abc".style("97")} ${"abc".style("107")}
""".stripMargin.block.text,
s"""\nBeispiel von vorhin: ${"<ESC>[31m".style(31)} schaltet auf rote Schrift um""".par,
),
slide("256 Farben")(
s"""|Die 256-Farbenpalette ist eine Erweiterung der 8/16-Farbenpalette.
|0-7 entsprechen denen der Terminalpalette, 8-15 den hellen Varianten.
|Dann folgen 216 Farben, die in einem 6x6x6-Würfel angeordnet sind:
|
| ${"".style(31)} Mit jedem Index steigt der Blauwert eine Stufe, nach 6 Farben wiederholt er sich
| ${"".style(32)} Mit jedem 6. Index steigt der Grünwert eine Stufe, analog.
| ${"".style(34)} Mit jedem 36. Index steigt der Rotwert eine Stufe
|
|Die restlichen 24 Farben sind Grautöne von schwarz nach weiß.
|
|Die Codes für die 256-Farbenpalette sind folgendermaßen aufgebaut:""".par,
s"Vordergrund: CSI ${"38".style(92)} : ${"5".style(93)} : ${"<Farbe>".style(95)} m".block.text,
s"Hintergrund: CSI ${"48".style(92)} : ${"5".style(93)} : ${"<Farbe>".style(95)} m".block.text,
s"""|
|${"✦ 5".style(93)} gibt an, dass die 256-Farben-Palette benutzt werden soll.
|${"✦ <Farbe>".style(95)} ist der Index der Farbe in der Palette.
|""".stripMargin.block.text,
PauseKey,
TypedCommand.interactiveUntyped("sh", "-c", "colortest -w | less -R").disable("", noInteractiveCommands),
),
slide("24 Bit \"Truecolor\"")(
s"""|Mit Truecolor gibt es keine Indices mehr.
|Es werden direkt die Werte für die Kanäle angegeben
|Der Aufbau ist ähnlich wie bei den 256 Farben (Vordergrund 38, Hintergrund 48):""".par,
s"CSI ${"38".style(92)} : ${"<Modell>".style(93)} : ${"<Farbraum>".style(95)} : ${"<Kanäle>".style(94)} m".block.text,
s"""|
|${"✦ <Modell>".style(93)} ist entweder 2 für RGB, 3 für CMY, 4 für CMYK (RGB am besten supported)
|${"✦ <Farbraum>".style(95)} gibt den Farbraum an. Schwierig, Doku dazu zu finden, meistens leer.
|${"✦ <Kanäle>".style(94)} sind die Werte für die einzelnen Kanäle in dezimal, getrennt durch :
| Nicht-standardisiertes, aber verbreitetes xterm-Encoding:
| Semikolon statt Doppelpunkt, Farbraumfeld fehlt.
|""".stripMargin.block.text,
s"""\nBeispiel: ${"<ESC>[38:2::255:166:86m".style(
"38:2::255:166:86",
)} ergibt den Orangeton RGB(255, 166, 86) bzw. #FFA656""".par,
),
slide("weitere Textformatierungen")(
"Weitere SGR-Codes zum Formatieren von Text (in Klammern: Code zum Abschalten):".par,
ulist(
s"1 (22): ${"Fett".style(1)}",
s"2 (22): ${"Faint".style(2)} (meistens nicht unterstützt oder wechselt nur zu dunklerer Farbe)",
s"3 (23): ${"Kursiv".style(3)}",
s"5 und 6 (25): ${"Blinkend".style(5)} und ${"schnell Blinkend".style(6)}, durchwachsener Support",
s"7 (27): ${"Invertiert".style(7)} (Vorder- und Hintergrundfarbe tauschen)",
s"8 (28): Versteckt (Vordergrundfarbe = Hintergrundfarbe)",
s"9 (29): ${"Durchgestrichen".style(9)}",
),
),
slide("weitere Textformatierungen")(
"Weitere SGR-Codes zum Formatieren von Text:".par,
ulist(
s"""|4 (24): ${"Unterstrichen".style(4)}. Moderne Terminals können zusätzlich noch:
| 4:1 ${"Normal unterstrichen".style(4, 1)}
| 4:2 ${"Doppelt unterstrichen".style(4, 2)}
| 4:3 ${"Unterringelt".style(4, 3)}
| 4:4 ${"gepunktet".style(4, 4)}
| 4:5 ${"gestrichelt".style(4, 5)}
|
| Außerdem kann in manchen Terminals die ${"Farbe".style(4).style("58:2::255:0:0")} gesetzt werden.
| 256er-Paletten- oder Truecolor-Code, einfach 38/48 durch 58 ersetzen.
|""".stripMargin,
),
),
slide("Bereiche formatieren")(
"""Mit der folgenden Escapesequenz kann man direkt einen größeren Bereich formatieren:""".par,
s"CSI ${"Pt; Pl; Pb; Pr".style(93)} ; ${"arg1 ; ... ; argn".style(95)} ${"$"}r".block.text,
s"""|
|${"✦ Pt; Pl; Pb; Pr".style(93)} geben den Bereich an, der formatiert werden soll
| Pt,Pl geben die erste Zelle an (top/left), Pb, Pr die letzte (bottom/right).
| Die oberste linke Zelle des Terminals ist hierbei 1,1.
| Weggelassene Werte entsprechen dem jeweiligen Extremwert.
|${"✦ arg1 ; ... ; argn".style(95)} sind ein SGR-Code (ohne das "m" am Ende)
| Formatiert standardmäßig zeilenweise alles von der ersten bis zur letzten Zelle.
| Mit ${"CSI 2 * x".style(3)} wechselt man stattdessen zu blockweiser Formatierung,
| mit ${"CSI * x".style(3)} wieder zu zeilenweise.
|\n""".stripMargin.block.text,
s"""|Standardisiert sind nur fett, unterstrichen, blinken und invertiert.
|Manche Terminals, z.B. kitty unterstützen alle SGRs, andere wiederum keine.""".par,
),
chapter("Cursor", "Bewegen und verstecken"),
slide("Einfache Cursorbewegung")(
s"""|Wenn wir komplexere Anwendungen bauen wollen, insbesondere interaktive,
|wollen wir nicht immer ein Zeichen nach dem anderen schreiben.
|Daher gibt es Escape-Codes, die den Cursor bewegen.
|
|Die folgenden Befehle sind alles CSI-Sequenzen mit 1-2 numerischen Argumenten.
|Fehlende Argumente entsprechen in den meisten Fällen einer 1.""".par,
ulist(
"A, B, C, D: Cursor um arg1 Zeichen nach oben, unten, rechts, links bewegen",
"E, F: Cursor auf Anfang der Zeile arg1 Zeilen unter/über aktueller Position setzen",
"G, d: Cursor auf Spalte bzw. Zeile arg1 setzen",
"H: Cursor auf Zeile arg1, Spalte arg2 setzen",
"s: Cursorposition speichern, u: zurück zur gespeicherten Position",
),
s"""|
|Für absolute Positionen ist die oberste linke Zelle (1, 1).
|Beispiel:
|${"<ESC>[HHello<ESC>[3;6HWorld".style(3)} gibt "Hello" oben links aus,
|"World" in der dritten Zeile um 6 Zellen nach rechts verschoben.""".par,
PauseKey,
TypedCommand.interactiveUntyped("./cursorpos.zsh").disable("", noInteractiveCommands),
),
slide("Cursordarstellung")(
"""Der Cursor kann auch versteckt werden (z.B. tut das diese Präsentation):""".par,
ulist(
s"${"CSI ? 25 l".style(3)} versteckt den Cursor",
s"${"CSI ? 25 h".style(3)} zeigt den Cursor wieder an",
),
s"""|Mit ${"CSI <n> ␠ q".style(3)} kann die Form des Cursors angepasst werden.
|Hierbei ist ein Leerzeichen und <n> eine der folgenden Zahlen:""".par,
ulist(
"0: Default-Cursor",
"1: Blinkender Block",
"2: Block",
"3: Blinkender Unterstrich",
"4: Unterstrich",
"5: Blinkender vertikaler Strich",
"6: vertikaler Strich",
),
),
slide("Dinge entfernen")(
"""|Oft ist es praktisch, Text auch wieder löschen zu können, z.B. um den Inhalt
|einer Zeile zu aktualisieren. Einfach überschreiben führt zu Artefakten, wenn
|der neue Text kürzer ist als der alte.""".par,
ulist(
s"${"CSI K".style(3)} oder ${"CSI 0 K".style(3)} löscht vom Cursor bis zum Ende der Zeile",
s"${"CSI 1 K".style(3)} löscht vom Anfang der Zeile bis zum Cursor",
s"${"CSI 2 K".style(3)} löscht die ganze Zeile",
s"${"CSI J".style(3)} oder ${"CSI 0 J".style(3)} löscht vom Cursor bis zum Ende des Bildschirms",
s"${"CSI 1 J".style(3)} löscht vom Anfang des Bildschirms bis zum Cursor",
s"${"CSI 2 J".style(3)} löscht den ganzen Bildschirm (was \"clear\" tut)",
s"${"CSI 3 J".style(3)} löscht den Bildschirm und Scrollback",
),
),
chapter("Weitere", "Features", "vor allem neuere"),
slide("Hyperlinks")(
"""|Hyperlinks sind ein relativ neues Feature in Terminals. Sie funktionieren ähnlich wie
|Links in Browsern.
|""".par,
s"Start: ${"OSC 8".style(92)} ; ${"<params>".style(93)} ; ${"<URI>".style(95)} ST".block.text,
s"Ende: ${"OSC 8".style(92)} ; ; ST".block.text,
s"""|
|${"✦ OSC 8".style(92)} gibt an, dass wir einen Hyperlink wollen (zur Erinnerung: ${"OSC = <ESC> ]".style(3)})
|${"✦ <params>".style(93)} sind key=value Paare, getrennt durch Doppelpunkt
| Aktuell ist nur "id" spezifiziert.
|${"✦ <URI>".style(95)} ist das Linkziel, URI-encoded. Erlaubte Schemas terminalabhängig.
| Wegen Remoteverbindungen wie SSH müssen file:// URIs den Hostnamen enthalten.
| ${"ST".style(3)} ist der String Terminator (ESC \\)""".stripMargin.block.text,
),
slide("Hyperlinks")(
s"""|Wozu die ID? Beispiel von egmontkob [1]:
|
| file1
| file2
| ${"http://exa".link(42, "http://example.com")}Lorem ipsum
| ${"le.com".link(42, "http://example.com")} dolor sit
| amet, conse
| ctetur adip
|
|
|Umgebrochener Link in der linken Box soll ein Link bleiben. Das Terminal interpretiert aber
|Attribute pro Zelle. ID ist nötig, um nicht aufeinanderfolgende Zellen zu gruppieren.
|
|
|[1]: ${"https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda".style(38, 5, 39)}""".par,
),
slide("Clipboard-Zugriff")(
s"""|Moderne Terminals erlauben es Programmen, auf das Clipboard zuzugreifen.
|Wozu, wenn das auch über "xclip" u.ä. geht?
| => Mit Escapecode funktioniert es auch über SSH!""".par,
s"${"OSC 52".style(92)} ; ${"<target>".style(93)} ; ${"<data>".style(95)} ST".block.text,
s"""|
|${"✦ OSC 52".style(92)} gibt an, dass wir auf das clipboard zugreifen wollen
|${"✦ <target>".style(93)} wählt das Clipboard, das wir nutzen möchten.
| "c" steht hierbei für das normale Clipboard, "p" für die "Primary selection".
|${"✦ <data>".style(95)} ist entweder ein "?", um aus dem Clipboard zu lesen,
| oder die base64-kodierten Daten, die reingeschrieben werden sollen.
|""".stripMargin.block.text,
s"""|
|Beispiel (von vorhin): ${"<ESC>]52;c;SGVsbG9Xb3JsZA==<ESC>\\".style(38, 5, 145)}
| kopiert "HelloWorld" in die Zwischenablage""".par,
),
slide("""Moment, da muss das Terminal antworten""")(
s"""|Bis eben hatten wir nur Escape-Codes, bei denen das Terminal nichts zurückgeben muss.
|Wie erhalten wir die Antworten vom Terminal? => via stdin, auch als Escapecodes.
|Allerdings arbeitet stdin standardmäßig im ${"canonical mode".emph}
| => Eingabe kommt erst nach Newline an
|
|Hier müssen wir tatsächlich via Syscalls das ${"Terminal Device".emph} umkonfigurieren.
|Unter unixoiden Betriebssystemen können wir dafür das stty-Programm benutzen:""".par,
s"${"stty".style(92)} ${"-icanon".style(93)} ${"min 1".style(95)}".block.text,
s"""|
|${"✦ stty".style(92)} ist ein Programm, das diverse Terminalsyscalls kapselt
|${"✦ -icanon".style(93)} deaktiviert den canonical mode => ungepuffertes stdin
|${"✦ min 1".style(95)} setzt die minimale Größe für reads auf ein Byte
| => einzelne Zeichen lesbar, z.B. zum lesen von Tastendrücken""".stripMargin.block.text,
s"""|
|Wer in einer kompilierten Sprache arbeitet und die Syscalls direkt benutzen will,
|Die relevanten Suchbegriffe sind tcgetattr, tcsetattr und termios.h
|Bzw. unter Windows: GetConsoleMode, SetConsoleMode und windows.h""".par,
),
slide("Clipboard auslesen")(
s"""|Zum Auslesen des Clipboards können wir nun die OSC52-Sequenz
|mit einem "?" als Datenparameter senden.
|
|Die Antwort des Terminals hat das selbe Format wie der Escapecode zum kopieren.
|Wenn wir also mit ${"<ESC>]52;c;SGVsbG9Xb3JsZA==<ESC>\\".style(38, 5, 145)} "HelloWorld"
|in die Zwischenablage schreiben können, würde auch genau dieser String zurück kommen
|wenn wir das Clipboard auslesen, während "HelloWorld" darin steht.""".par,
),
slide("Cursorposition auslesen")(
s"""|Nachdem wir jetzt wissen, wie uns das Terminal Dinge antworten kann, können wir
|auch noch einen CSI-Code anschauen, den wir vorhin ausgelassen haben:""".par,
s"CSI ${"6 n".style(92)} ".block.text,
s"""|
|Auf diese Sequenz antwortet das Terminal mit der aktuellen Cursorposition, im Format:""".par,
s"CSI ${"<ROW>".style(92)} ; ${"<COL>".style(92)} ${"".style(95)} ".block.text,
s"""|
|In Kombination mit anderen Escapes, die wir schon gesehen haben, kann man auf etwas
|hackige Art die Größe des Terminals bekommen:""".par,
s"${"CSI s".style(92)} ${"CSI 999;999 H".style(93)} ${"CSI 6 n".style(94)} ${"CSI u".style(96)}".block.text,
),
slide("Bilder im Terminal")(
s"""|Man kann mittlerweile in vielen Terminals Grafiken ausgeben. Allerdings gibt es dafür
|verschiedene Protokolle und die meisten Emulatoren können nur eines.""".par,
ulist(
s"""|${"sixel".emph}: Ein altes Protokoll, das schon von Hardwareterminals unterstützt wurde
|Eingeschränkt auf 1024 Farbwerte pro Bild""".stripMargin,
s"""|${"iTerm2 Protocol".emph}: proprietäres, relativ einfaches Protokoll von iTerm2.
|Benutzt Escapes mit unbegrenzter Länge
| => Problemen wenn nicht supported""".stripMargin,
s"""|${"Kitty Graphics Protocol".emph}: proprietäres, aber ausführlich spezifiziertes Protokoll
|von kitty. Hat sehr viele Konfigurationsmöglichkeiten, benutzt eine APC-Sequenz.
|Auch von wenigen anderen implementiert (u.a. KDE Konsole).
|Support über Query prüfbar, wird ignoriert wenn nicht implementiert""".stripMargin,
),
),
slide("Bilder im Terminal")(
"Mit dem Kitty-Protokoll geht das auch in bewegt:".par,
TypedCommand.interactiveUntyped("kitten", "icat", "img/earth.gif"),
),
chapter("Ende", "... des vorbereiteten Teils ;)"),
slide("Sonstige Escapes")(
"Ein paar weitere Typen von Escapesequenzen, die wir uns nicht genauer anschauen".par,
ulist(
"DCS (Device Control String): ähnlich wie OSC, kaum noch genutzt.\nU.a. für ältere Grafikprotokolle",
"SCS, DOCS: für Zeichensatzwechsel. Wir haben jetzt Unicode.",
"DEC: Für Dinge wie doppelte Zeilenhöhe. In manchen Terminals noch supported.",
"SM, RM: Setzen von Terminalmodi, war für Hardwareterminals relevant",
"DECSET, DECRST: Noch mehr Terminalmodi, vendorspezifisch.\nWerden noch genutzt, z.B. für Maus-Support",
),
),
)
val presentation = Presentation(slides, meta = meta)
presentation.start(using
Keymap.default ++ Map(
Key('i') -> SlideAction.runForeground("tmux"),
),
)

8
examples/termescape/title.zsh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/zsh
printf '\e[?25l\e[2J'
while true; do
printf "\e[$((LINES / 2 - 14));0H"
${ZSH_SCRIPT:a:h}/figlet-pagga-block -p 8 "" Bunte Terminals "" "und sonstiger" "Spass mit" ESCAPECODES "" | lolcat --24bit -r
read -s -k -t 0.5 && break
done
printf '\e[?25h'

48
mill
View file

@ -1,48 +0,0 @@
#!/usr/bin/env sh
# This is a wrapper script, that automatically download mill from GitHub release pages
# You can give the required mill version with MILL_VERSION env variable
# If no version is given, it falls back to the value of DEFAULT_MILL_VERSION
DEFAULT_MILL_VERSION=0.9.5
set -e
if [ -z "$MILL_VERSION" ] ; then
if [ -f ".mill-version" ] ; then
MILL_VERSION="$(head -n 1 .mill-version 2> /dev/null)"
elif [ -f "mill" ] && [ "$BASH_SOURCE" != "mill" ] ; then
MILL_VERSION=$(grep -F "DEFAULT_MILL_VERSION=" "mill" | head -n 1 | cut -d= -f2)
else
MILL_VERSION=$DEFAULT_MILL_VERSION
fi
fi
if [ "x${XDG_CACHE_HOME}" != "x" ] ; then
MILL_DOWNLOAD_PATH="${XDG_CACHE_HOME}/mill/download"
else
MILL_DOWNLOAD_PATH="${HOME}/.cache/mill/download"
fi
MILL_EXEC_PATH="${MILL_DOWNLOAD_PATH}/${MILL_VERSION}"
version_remainder="$MILL_VERSION"
MILL_MAJOR_VERSION="${version_remainder%%.*}"; version_remainder="${version_remainder#*.}"
MILL_MINOR_VERSION="${version_remainder%%.*}"; version_remainder="${version_remainder#*.}"
if [ ! -x "$MILL_EXEC_PATH" ] ; then
mkdir -p $MILL_DOWNLOAD_PATH
if [ "$MILL_MAJOR_VERSION" -gt 0 ] || [ "$MILL_MINOR_VERSION" -ge 5 ] ; then
ASSEMBLY="-assembly"
fi
DOWNLOAD_FILE=$MILL_EXEC_PATH-tmp-download
MILL_DOWNLOAD_URL="https://github.com/lihaoyi/mill/releases/download/${MILL_VERSION%%-*}/$MILL_VERSION${ASSEMBLY}"
curl --fail -L -o "$DOWNLOAD_FILE" "$MILL_DOWNLOAD_URL"
chmod +x "$DOWNLOAD_FILE"
mv "$DOWNLOAD_FILE" "$MILL_EXEC_PATH"
unset DOWNLOAD_FILE
unset MILL_DOWNLOAD_URL
fi
unset MILL_DOWNLOAD_PATH
unset MILL_VERSION
exec $MILL_EXEC_PATH "$@"