Move presentation tooling to its own repo

This commit is contained in:
Alexander Gehrke 2021-02-19 15:46:41 +01:00
commit 3ea943b1c4
10 changed files with 563 additions and 0 deletions

52
copret/src/Theme.scala Normal file
View file

@ -0,0 +1,52 @@
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 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 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")
)
}
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 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)
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)
texts.init.mkString(pad) + pad + " " * (totalPad % numPads) + texts.last
}
private[copret] val ticks = raw"`([^`]*)`".r
}
/* vim:set tw=120: */

86
copret/src/keys.scala Normal file
View file

@ -0,0 +1,86 @@
package de.qwertyuiop.copret
import ammonite.ops.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
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))
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,
))
}
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', '~')
val PageUp = List(codes.Esc, '[', '5', '~')
val PageDown = List(codes.Esc, '[', '6', '~')
val Home = List(codes.Esc, '[', 'H')
val End = List(codes.Esc, '[', 'F')
val F1 = List(codes.Esc, 'P')
val F2 = List(codes.Esc, 'Q')
val F3 = List(codes.Esc, 'R')
val F4 = List(codes.Esc, 'S')
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', '~')
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 Tab = List('\t')
val Up = List(codes.Esc, '[', 'A')
val Down = List(codes.Esc, '[', 'B')
val Right = List(codes.Esc, '[', 'C')
val Left = List(codes.Esc, '[', 'D')
def apply(char: Char): List[Int] = List(char.toInt)
}
/* vim:set tw=120: */

192
copret/src/slides.scala Normal file
View file

@ -0,0 +1,192 @@
package de.qwertyuiop.copret
import ammonite.ops._
import Terminal._
import syntax._
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) =>
try { %("imgcat", file.toString)(pwd) }
catch { case _: InteractiveShelloutException => println(s"Image missing: $file") }
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)
}
}
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) extends Slide
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 {
private lazy val _output = exec(cmd)
def output = _output
def display(s: String): TypedCommand[T] = TypedCommand(exec, s, cmd)
def show() = {
prompt()
typeCmd()
print(output)
}
def quickShow() = {
prompt()
println(display)
print(output)
}
def prompt() = print(fansi.Color.LightGreen("user@host % "))
def force() = _output
private def typeCmd() = {
for (char <- display) {
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) => "")
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)
else this
}
object TypedCommand {
val shell = sys.env.getOrElse("SHELL", "sh")
def run(implicit wd: Path): Vector[String] => String =
c => safe_%%(c)
def runShell(implicit wd: Path): Vector[String] => String =
c => safe_%%(Vector(shell, "-c", c.mkString(" ")))
def runInteractive(implicit wd: Path): Vector[String] => String =
c => { %(c); ""}
def apply(cmd: String*)(implicit wd: Path): TypedCommand[Vector[String]] =
TypedCommand(run, cmd.mkString(" "), cmd.toVector)
def shell(cmd: String*)(implicit wd: Path): TypedCommand[Vector[String]] =
TypedCommand(runShell, cmd.mkString(" "), cmd.toVector)
def fake(cmd: String): TypedCommand[String] =
TypedCommand(_ => "", cmd, cmd)
def interactive(cmd: String*)(implicit wd: Path): TypedCommand[Vector[String]] =
TypedCommand(runInteractive, cmd.mkString(" "), cmd.toVector)
}
sealed abstract case class Silent[T] private (doStuff: () => T) extends Slide
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) }
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) {
def useIn(display: T => Slide) = LazyIOSlide(() => runOnce, display)
}
def prepare[T](runOnce: => T): LazyIOSlideBuilder[T] = new LazyIOSlideBuilder(runOnce)
}
/* vim:set tw=120: */

38
copret/src/syntax.scala Normal file
View file

@ -0,0 +1,38 @@
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)
def centered = center(str)
def block = centerBlock(str)
def right = alignRight(str)
def right(padding: Int) = alignRight(str, padding)
def padLeft(padding: Int) = {
val pad = " " * padding
str.linesIterator.map(pad + _).mkString("\n")
}
def blue = fansi.Color.Blue(str)
def green = fansi.Color.Green(str)
def yellow = fansi.Color.Yellow(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)
def blue = fansi.Color.Blue(str)
def green = fansi.Color.Green(str)
def yellow = fansi.Color.Yellow(str)
def red = fansi.Color.Red(str)
}
}
/* vim:set tw=120: */

View file

@ -0,0 +1,25 @@
package de.qwertyuiop.copret
import syntax._
import ammonite.ops.{Path, %, %%, pwd}
trait Templates {
def titleLine(title: String)(implicit 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", "")
val center = p.meta.getOrElse("title", "")
val right = s"${pos} / ${p.slides.size - 1}"
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 markdown(title: String, content: Path) = slide(title)(IncludeMarkdown(content))
lazy val --- = Paragraph(("═" * columns).yellow)
}
/* vim:set tw=120: */

60
copret/src/terminal.scala Normal file
View file

@ -0,0 +1,60 @@
package de.qwertyuiop.copret
import ammonite.ops.{Path, ShelloutException, pwd, %, %%}
import org.jline.terminal.TerminalBuilder
import org.jline.reader.LineReaderBuilder
object Terminal {
def safe_%%(cmd: Vector[String])(implicit wd: Path): String =
try {
%%(cmd).out.string
} catch {
case e: ShelloutException => e.result.err.string
}
def tryCmd[T](cmd: => T, default: => T) =
try { cmd } catch { case e: ShelloutException => default }
def enterRawMode(): Unit = {
%%("sh", "-c", "stty -icanon min 1 < /dev/tty")(pwd)
%%("sh", "-c", "stty -echo < /dev/tty")(pwd)
}
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 = {
// ignore keypresses done during slide animations
while(Console.in.ready()) Console.in.read
var key = scala.collection.mutable.ArrayBuffer[Int]()
key += Console.in.read
while(Console.in.ready)
key += Console.in.read
keymap(key.toList)
}
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
}
}
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
}
/* vim:set tw=120: */