diff --git a/build.sc b/build.sc deleted file mode 100644 index 662071b..0000000 --- a/build.sc +++ /dev/null @@ -1,31 +0,0 @@ -import mill._, scalalib._, publish._ -import $ivy.`com.lihaoyi::mill-contrib-bloop:0.9.5` - -object copret extends ScalaModule with PublishModule { - def scalaVersion = "3.1.0" - override def ammoniteVersion = "2.5.3" - - - def publishVersion = "0.0.2-SNAPSHOT" - def pomSettings = PomSettings( - description = "Use ammonite scripts for command line presentations", - organization = "de.qwertyuiop", - versionControl = VersionControl( - browsableRepository = Some("https://git.qwertyuiop.de/crater2150/copret"), - connection = Some(VersionControlConnection.gitHttps("git.qwertyuiop.de", "crater2150/copret.git")), - developerConnection = Some(VersionControlConnection.gitSsh( - "qwertyuiop.de", "crater2150/copret.git", username = Some("git") - )), - ), - url = "https://qwertyuiop.de/copret/", - licenses = Seq(License.MIT), - developers = Seq(Developer("crater2150", "Alexander Gehrke", "https://git.qwertyuiop.de/crater2150")) - ) - - def ivyDeps = Agg( - ivy"org.jline:jline:3.19.0", - ivy"com.lihaoyi::ammonite-ops:2.3.8".withDottyCompat(scalaVersion()), - ivy"com.lihaoyi::fansi:0.2.14", - ) -} - diff --git a/copret/.scalafmt.conf b/copret/.scalafmt.conf new file mode 100644 index 0000000..feb071c --- /dev/null +++ b/copret/.scalafmt.conf @@ -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 diff --git a/copret/project.scala b/copret/project.scala new file mode 100644 index 0000000..a832969 --- /dev/null +++ b/copret/project.scala @@ -0,0 +1,10 @@ +//> 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.10.0 +//> using dep org.jline:jline:3.26.1 +//> using dep com.lihaoyi::os-lib:0.10.0 +//> using dep com.lihaoyi::fansi:0.5.0 diff --git a/copret/src/Theme.scala b/copret/src/Theme.scala index f208f7a..e214be3 100644 --- a/copret/src/Theme.scala +++ b/copret/src/Theme.scala @@ -1,6 +1,8 @@ package de.qwertyuiop.copret import de.qwertyuiop.copret.syntax._ -import ammonite.ops.{%%, pwd} + +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()) = @@ -28,7 +30,7 @@ object Format: 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) = diff --git a/copret/src/images.scala b/copret/src/images.scala new file mode 100644 index 0000000..6820a36 --- /dev/null +++ b/copret/src/images.scala @@ -0,0 +1,139 @@ +package de.qwertyuiop.copret + +import Terminal.* + +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 showImage(img: os.Path) = + import java.util.Base64 + val image = Base64.getEncoder.encodeToString(os.read.bytes(img)) + if image.length > 4096 then + val chunks = image.grouped(4096).toVector + + 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}" + + 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 diff --git a/copret/src/keys.scala b/copret/src/keys.scala index 1924b57..9360d6a 100644 --- a/copret/src/keys.scala +++ b/copret/src/keys.scala @@ -1,6 +1,6 @@ package de.qwertyuiop.copret -import ammonite.ops.Path +import os.Path enum SlideAction: case Start @@ -15,50 +15,59 @@ enum SlideAction: case Other(code: List[Int]) def show: String = this match - case Start => "go to first slide" + 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 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) + def runForeground(cmd: String*)(implicit wd: Path) = + Interactive(cmd.toVector, wd) import SlideAction.* 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)) + 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 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") + bindings.toSeq + .sortBy(_._2.toString) + .map((k, v) => k.show.padTo(8, ' ') + " " + v.show) + .mkString("\n") object Keymap: - val empty = Keymap(Map()) - 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, - )) + 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 enum Key: case Code(name: String, codepoints: List[Int]) @@ -66,13 +75,13 @@ enum Key: def codes: List[Int] = this match - case Code(_, cp) => cp + case Code(_, cp) => cp case Printable(char) => List(char.toInt) def show: String = this match case Code(name, _) => name - case Printable(c) => c.toString + case Printable(c) => c.toString object Key: def apply(char: Char): Key = Printable(char) diff --git a/copret/src/main.scala b/copret/src/main.scala new file mode 100644 index 0000000..16f7649 --- /dev/null +++ b/copret/src/main.scala @@ -0,0 +1,16 @@ +package de.qwertyuiop.copret + +import de.qwertyuiop.copret.Terminal.* + +@main def main = + enterRawMode() + // print(queryTerm(s"${apc}Gi=31,s=10,v=2,t=s;L3NvbWUtc2hhcmVkLW1lbW9yeS1uYW1lCg==\u001b\\")) + // print(queryTerm(s"\u001b[6n")) + val canHazGraphics = queryTerm(s"${apc}Gi=${KittyGraphicsProtocol.MaxID},s=1,v=1,a=q,t=d,f=24;AAAA${st}${csi}c") + println(canHazGraphics.contains(s"${apc}Gi=${KittyGraphicsProtocol.MaxID}")) + // println( + // KittyGraphicsProtocol.showImage( + // os.Path("/home/crater2150/org/opencolloq/gittalk/img/repo-graph.png"), + // ), + // ) + // println(getSize) diff --git a/copret/src/slides.scala b/copret/src/slides.scala index 58a8406..eaa2557 100644 --- a/copret/src/slides.scala +++ b/copret/src/slides.scala @@ -1,103 +1,121 @@ 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): +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) = import SlideAction.* - @annotation.tailrec def rec(p: Presentation, pos: Int, action: SlideAction): Unit = + @annotation.tailrec + def rec(pos: Int, action: SlideAction): Unit = + + inline def redraw() = rec(pos - 1, QuickNext) + + inline def navigate(pos: Int, condition: Boolean, direction: Int)( + executor: Int => Slide => Unit, + ) = + if condition then + executor(pos + direction)(slides(pos)) + rec(pos + direction, waitkey) + else rec(pos, waitkey) + + inline def runInteractive(cmd: Vector[String], path: Path) = + Terminal.showCursor() + try os.proc(cmd).call(cwd = path) + catch case _ => () + Terminal.hideCursor() + Terminal.clear() + redraw() + action match - case Start => - executeSlide(p, 0)() - rec(p, 0, waitkey) - case Next => - if pos + 1 < p.slides.size then - executeSlide(p, pos + 1)() - rec(p, pos + 1, waitkey) - else - rec(p, pos, waitkey) - case QuickNext => - if pos + 1 < p.slides.size then - executeQuick(p, pos + 1)() - rec(p, pos + 1, waitkey) - else rec(p, pos, waitkey) - case Prev => - if pos > 0 then - executeQuick(p, pos - 1)() - rec(p, pos - 1, waitkey) - else - rec(p, pos, waitkey) - case Interactive(cmd, path) => - Terminal.showCursor() - try - %(cmd)(path) - catch case _ => () - Terminal.hideCursor() - Terminal.clear() - rec(p, pos - 1, QuickNext) + case Start => navigate(0, true, 0)(executeSlide) + case Next => navigate(pos, pos + 1 < slides.size, 1)(executeSlide) + case QuickNext => navigate(pos, pos + 1 < slides.size, 1)(executeQuick) + case Prev => navigate(pos, pos > 0, -1)(executeQuick) + case Interactive(cmd, path) => runInteractive(cmd, path) + case Goto(target) => for i <- 0 until target - do executeSilent(p, i)() - rec(p, target - 1, QuickNext) + do executeSilent(i)() + rec(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) + promptSlide() match + case Some(i) => rec(pos, Goto(i)) + case None => redraw() + case Help => Terminal.clear() println(keymap.help) waitkey - rec(p, pos - 1, QuickNext) + redraw() + case Other(codes) => Terminal.printStatus(action.show) - rec(p, pos, waitkey) + rec(pos, waitkey) + case Quit => - () - rec(this, 0, Start) + Terminal.showCursor() + end match + end rec + rec(0, Start) + end run + def promptSlide() = + val maxSlide = slides.size - 1 + 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)", + ) -object Presentation: - def executeSlide(p: Presentation, pos: Int)(slide: Slide = p.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 incMd @ IncludeMarkdown(_) => println(incMd.markdownBlock()) - case Image(file, None) => Terminal.showImage(file) - case Image(file, Some(ImageSize(w, h, aspect))) => Terminal.showImageScaled(file, w, h, aspect) - 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)) + 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 incMd @ IncludeMarkdown(_) => println(incMd.markdownBlock()) + case Image(file, None) => println(KittyGraphicsProtocol.showImage(file)) + case Image(file, Some(ImageSize(w, h, aspect))) => + println(KittyGraphicsProtocol.showImage(file)) // 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(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) + 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 | IncludeMarkdown(_) | Meta(_) => () + case _ => executeQuick(pos)(slide) +end Presentation case class ImageSize(width: Double, height: Double, keepAspect: Boolean) @@ -105,7 +123,7 @@ sealed trait 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) + val pad = "\n" * ((height - lines) / 2) Paragraph(pad + contents + pad) object Paragraph: @@ -113,46 +131,56 @@ object Paragraph: case class IncludeMarkdown(path: Path) extends Slide: def markdownBlock() = - %%%("/usr/bin/mdcat", "--columns", (columns * 0.8).toInt.toString, path.toString)(using ImplicitWd.implicitCwd).block + %%%( + "/usr/bin/mdcat", + "--columns", + (columns * 0.8).toInt.toString, + path.toString, + )(using os.pwd).block case class Image(path: Path, sizing: Option[ImageSize]) extends Slide object Image: - def apply(path: Path) = new Image(path, None) + 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 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, cmdIsHidden: Boolean, outputIsHidden: Boolean) 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 output = _output - infix def showing (s: String): TypedCommand[T] = TypedCommand(exec, s, cmd) + infix def showing(s: String): TypedCommand[T] = TypedCommand(exec, s, cmd) def show() = force() - if ! cmdIsHidden then + if !cmdIsHidden then prompt() Terminal.showCursor() typeCmd() - if ! outputIsHidden then - print(output) - if ! cmdIsHidden then - Terminal.hideCursor() + if !outputIsHidden then print(output) + if !cmdIsHidden then Terminal.hideCursor() def quickShow() = force() - if ! cmdIsHidden then + if !cmdIsHidden then prompt() println(display) - if ! outputIsHidden then - print(output) + if !outputIsHidden then print(output) def prompt() = print(fansi.Color.LightGreen("user@host % ")) - def force() = _output + def force() = _output + + def display(s: String) = copy(display = s) private def typeCmd() = for char <- display do @@ -162,7 +190,7 @@ case class TypedCommand[T](exec: T => String, display: String, cmd: T, cmdIsHidd /* 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 then 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, @@ -170,19 +198,19 @@ case class TypedCommand[T](exec: T => String, display: String, cmd: T, cmdIsHidd def replaceIf(condition: Boolean)(tc: TypedCommand[_]): TypedCommand[_] = if condition then tc.showing(display) else this - +end TypedCommand object TypedCommand: val shell = sys.env.getOrElse("SHELL", "sh") def run(using Path): Vector[String] => String = - c => safe_%%(c) + c => runProcess(c) def runShell(using Path): Vector[String] => String = - c => safe_%%(Vector(shell, "-c", c.mkString(" "))) + c => runProcess(Vector(shell, "-c", c.mkString(" "))) def runInteractive(using Path): Vector[String] => String = - c => { %(c); ""} + c => { os.proc(c).call(); "" } def apply(cmd: String*)(using Path): TypedCommand[Vector[String]] = TypedCommand(run, cmd.mkString(" "), cmd.toVector) @@ -190,7 +218,6 @@ object TypedCommand: def apply[T](exec: T => String, display: String, cmd: T) = new TypedCommand(exec, display, cmd, false, false) - def shell(cmd: String*)(using Path): TypedCommand[Vector[String]] = TypedCommand(runShell, cmd.mkString(" "), cmd.toVector) @@ -200,11 +227,9 @@ object TypedCommand: def interactive(cmd: String*)(using 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){} - + def apply[T](doStuff: => T) = new Silent(() => doStuff) {} case class Group(slides: List[Slide]) extends Slide object Group: @@ -218,7 +243,8 @@ trait SlideSyntax: private[copret] class LazyIOSlideBuilder[T](runOnce: => T): def useIn(display: T => Slide) = LazyIOSlide(() => runOnce, display) - def prepare[T](runOnce: => T): LazyIOSlideBuilder[T] = LazyIOSlideBuilder(runOnce) - + def prepare[T](runOnce: => T): LazyIOSlideBuilder[T] = LazyIOSlideBuilder( + runOnce, + ) /* vim:set tw=120: */ diff --git a/copret/src/templates.scala b/copret/src/templates.scala index ee5447b..56297be 100644 --- a/copret/src/templates.scala +++ b/copret/src/templates.scala @@ -1,11 +1,14 @@ package de.qwertyuiop.copret import syntax.* -import ammonite.ops.{Path, %, %%, pwd, ImplicitWd} +import os.Path trait Templates: def titleLine(title: String)(using theme: Theme) = Paragraph( - "\n" + Format.figlet(title, theme.font("titleLine", "pagga")).block.blue + "\n" - ) + "\n" + Format + .figlet(title, theme.font("titleLine", "pagga")) + .block + .blue + "\n" + ) def header(using theme: Theme) = Meta((p, pos) => { val left = p.meta.getOrElse("author", "") @@ -14,12 +17,21 @@ trait Templates: theme.style("titleLine")(Format.distribute(left, center, right)).text }) - 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 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)(using Theme) = slide(title)( Paragraph( - %%%("/usr/bin/mdcat", "--columns", (columns * 0.8).toInt.toString, content.toString)(using ImplicitWd.implicitCwd).block + %%%( + "/usr/bin/mdcat", + "--columns", + (columns * 0.8).toInt.toString, + content.toString + )(using os.pwd).block ) ) diff --git a/copret/src/terminal.scala b/copret/src/terminal.scala index 45c9fd5..6b45062 100644 --- a/copret/src/terminal.scala +++ b/copret/src/terminal.scala @@ -1,98 +1,112 @@ 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])(using Path): String = - try - %%(cmd).out.string - catch - case e: ShelloutException => e.result.err.string + 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: os.SubprocessException => default def enterRawMode(): Unit = - %%("sh", "-c", "stty -icanon min 1 < /dev/tty")(pwd) - %%("sh", "-c", "stty -echo < /dev/tty")(pwd) + 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) - extension (percent: Double) - def toPercent: String = s"${(percent * 100).toInt}%" + 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 height = jterm.getSize.getRows - def width = jterm.getSize.getColumns - - def waitkey(using keymap: Keymap): SlideAction = + def waitkey(implicit keymap: Keymap): SlideAction = // ignore keypresses done during slide animations - while Console.in.ready() do 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 do - key += Console.in.read + key += System.in.read + while (System.in.available() > 0) + key += System.in.read keymap(key.toList) - def printStatus(msg: String): Unit = - cursorTo(height, 1) - print(msg) + 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.println(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) then - 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 - val term = sys.env("TERM") + def isTmux = sys.env.contains("TMUX") || sys.env("TERM").startsWith("screen") - def isTmux = sys.env.contains("TMUX") || term.startsWith("screen") + def csi = if (isTmux) "\u001bPtmux\u001b\u001b[" else "\u001b[" + def osc = if (isTmux) "\u001bPtmux\u001b\u001b]" else "\u001b]" + def apc = if (isTmux) "\u001bPtmux;\u001b\u001b_" else "\u001b_" + def st = if (isTmux) "\u0007\u001b\\" else "\u0007" - def osc(code: String) = (if isTmux then "\u001bPtmux\u001b\u001b]" else "\u001b]") + code + st - def st = if isTmux then "\u0007\u001b\\" else "\u0007" - def csi(code: String) = "\u001b[" + code + 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 hideCursor() = print(csi("?25l")) - def showCursor() = print(csi("?25h")) - def cursorTo(row: Int, col: Int) = print(csi(s"${row};${col}H")) - def clear() = print(csi("2J") + csi(";H")) - - def showImage(img: Path): Unit = - print( - if term == "xterm-kitty" then showImageKitty(img) - else showImageIterm(img, "100%", "100%", true) - ) - - def showImageScaled(img: Path, width: Double, height: Double, keepAspect: Boolean): Unit = - print( - if term == "xterm-kitty" then - val cols = (jterm.getSize.getColumns * width).toInt - val rows = (jterm.getSize.getRows * height).toInt - showImageKitty(img) // TODO - else - showImageIterm(img, width.toPercent, height.toPercent, keepAspect) - ) - - def showImageIterm(img: Path, width: String, height: String, keepAspect: Boolean = true): String = + 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 then 1 else 0 - osc(s"1337;File=inline=1;width=$width;height=$height;preserveAspectRatio=$aspect:$image") + 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 - def showImageKitty(img: Path): String = - import java.util.Base64 - s"\u001b_Gf=100,t=f,a=T,C=1;${Base64.getEncoder.encodeToString(img.toString.toCharArray.map(_.toByte))}\u001b\\" + 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}" + + def getSize = queryTerm(s"${csi}s${csi}999;999H${csi}6n${csi}u") + + private val SizeResponse = """\u001b\[4;(\d+);(\d+)t""".r + + case class PixelSize(width: Int, height: Int) + def getSizePixels: Option[PixelSize] = queryTerm(s"${csi}14t") match + case SizeResponse(rows, cols) => Some(PixelSize(cols.toInt, rows.toInt)) + case _ => None + +end Terminal private[copret] trait TerminalSyntax: import Terminal._ - def %%%(cmd: String*)(using 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: */ diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..deaa7bf --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +demoRepo diff --git a/examples/git-presentation.sc b/examples/git-presentation.sc new file mode 100755 index 0000000..5a06592 --- /dev/null +++ b/examples/git-presentation.sc @@ -0,0 +1,425 @@ +#!/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, + /* 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")(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 ` + 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: + | ` ` + |""".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 ` (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 ` + |Alternativ: Branch anlegen und direkt auf diesen wechseln mit `git switch -c ` + |""".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//` + |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: */ diff --git a/examples/img/git-logo.png b/examples/img/git-logo.png new file mode 100644 index 0000000..f4518b1 Binary files /dev/null and b/examples/img/git-logo.png differ diff --git a/examples/img/repo-graph-i.png b/examples/img/repo-graph-i.png new file mode 100644 index 0000000..c9cd7c3 Binary files /dev/null and b/examples/img/repo-graph-i.png differ diff --git a/examples/img/repo-graph.png b/examples/img/repo-graph.png new file mode 100644 index 0000000..bcf5a40 Binary files /dev/null and b/examples/img/repo-graph.png differ diff --git a/examples/present.sc b/examples/present.sc deleted file mode 100755 index 94768e7..0000000 --- a/examples/present.sc +++ /dev/null @@ -1,375 +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.{given, *} - - -/* 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" -import de.qwertyuiop.copret.{given Theme} - -/* 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) showing 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")(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) showing "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 ` 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: - | ` ` - |""".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 ` (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 ` - |Alternativ: Branch anlegen und direkt auf diesen wechseln mit `git switch -c ` - |""".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") showing "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") showing "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//` - |Objekte sind komprimiert. - |""".par, - sh("tree -C .git/objects/ | head -n 11") showing "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: */ diff --git a/examples/query.sc b/examples/query.sc new file mode 100755 index 0000000..d3734b3 --- /dev/null +++ b/examples/query.sc @@ -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") diff --git a/examples/slides/01wozu.md b/examples/slides/01wozu.md new file mode 100644 index 0000000..02cf86b --- /dev/null +++ b/examples/slides/01wozu.md @@ -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 +- ... diff --git a/examples/slides/02branches.md b/examples/slides/02branches.md new file mode 100644 index 0000000..0e7a244 --- /dev/null +++ b/examples/slides/02branches.md @@ -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) + diff --git a/examples/slides/02summary.md b/examples/slides/02summary.md new file mode 100644 index 0000000..ebd06fc --- /dev/null +++ b/examples/slides/02summary.md @@ -0,0 +1,16 @@ +- `git init [dir]`: + neues Repo anlegen +- `git status`: + Aktuellen Zustand zeigen (geänderte, ungetrackte Dateien, Stagingbereich) +- `git add `: + Datei zu Repo hinzufügen / Änderungen in Datei für Commit markieren / "stagen" +- `git restore --staged `: + Änderungen aus Staging wieder herausnehmen +- `git restore `: + Ä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) +