commit 3ea943b1c4cae5b07cedb60cd446ac82c245140a Author: Alexander Gehrke Date: Fri Feb 19 15:46:41 2021 +0100 Move presentation tooling to its own repo diff --git a/3rd-party-licenses/jline3.LICENSE.txt b/3rd-party-licenses/jline3.LICENSE.txt new file mode 100644 index 0000000..7e11b67 --- /dev/null +++ b/3rd-party-licenses/jline3.LICENSE.txt @@ -0,0 +1,35 @@ +Copyright (c) 2002-2018, the original author or authors. +All rights reserved. + +https://opensource.org/licenses/BSD-3-Clause + +Redistribution and use in source and binary forms, with or +without modification, are permitted provided that the following +conditions are met: + +Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with +the distribution. + +Neither the name of JLine nor the names of its contributors +may be used to endorse or promote products derived from this +software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, +BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, +OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING +IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..87037c2 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# copret - COnsole PREsentation Tool diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..d214c67 --- /dev/null +++ b/build.sc @@ -0,0 +1,26 @@ +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", + ) +} + diff --git a/copret/src/Theme.scala b/copret/src/Theme.scala new file mode 100644 index 0000000..cc6f404 --- /dev/null +++ b/copret/src/Theme.scala @@ -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: */ diff --git a/copret/src/keys.scala b/copret/src/keys.scala new file mode 100644 index 0000000..61bb8e0 --- /dev/null +++ b/copret/src/keys.scala @@ -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: */ diff --git a/copret/src/slides.scala b/copret/src/slides.scala new file mode 100644 index 0000000..c5d6eaa --- /dev/null +++ b/copret/src/slides.scala @@ -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: */ diff --git a/copret/src/syntax.scala b/copret/src/syntax.scala new file mode 100644 index 0000000..56fd6af --- /dev/null +++ b/copret/src/syntax.scala @@ -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: */ diff --git a/copret/src/templates.scala b/copret/src/templates.scala new file mode 100644 index 0000000..10c9318 --- /dev/null +++ b/copret/src/templates.scala @@ -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: */ diff --git a/copret/src/terminal.scala b/copret/src/terminal.scala new file mode 100644 index 0000000..ddf4bb9 --- /dev/null +++ b/copret/src/terminal.scala @@ -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: */ diff --git a/mill b/mill new file mode 100755 index 0000000..2df1be5 --- /dev/null +++ b/mill @@ -0,0 +1,48 @@ +#!/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 "$@"