diff --git a/copret/project.scala b/copret/project.scala index 21b9863..441be75 100644 --- a/copret/project.scala +++ b/copret/project.scala @@ -4,7 +4,8 @@ //> 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.1 +//> using dep org.typelevel::cats-core:2.12.0 +//> using dep org.jline:jline:3.28.0 +//> using dep com.lihaoyi::os-lib:0.11.3 //> using dep com.lihaoyi::fansi:0.5.0 +//> using test.dep com.lihaoyi::utest:0.8.4 diff --git a/copret/src/Presentation.scala b/copret/src/Presentation.scala index a978cf5..e90c84f 100644 --- a/copret/src/Presentation.scala +++ b/copret/src/Presentation.scala @@ -96,7 +96,8 @@ case class Presentation( 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 + import KittyGraphicsProtocol.Sizing.* + println(KittyGraphicsProtocol.showImage(file, Absolute(w.toInt), Absolute(h.toInt), aspect)) // TODO case cmd: TypedCommand[_] => cmd.show() case Silent(actions) => actions() case Group(slides) => slides.foreach(executeSlide(pos)) diff --git a/copret/src/images.scala b/copret/src/images.scala index 15c862c..cb44aa4 100644 --- a/copret/src/images.scala +++ b/copret/src/images.scala @@ -1,6 +1,8 @@ package de.qwertyuiop.copret import Terminal.* +import scala.util.Try +import javax.imageio.ImageIO object KittyGraphicsProtocol: val MaxID = 4294967295L // max 32-bit unsigned @@ -9,24 +11,104 @@ object KittyGraphicsProtocol: 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) = + def imageSize(img: os.Path) = + Try(ImageIO.read(img.toIO)).toOption.map: image => + SizePx(image.getWidth, image.getHeight) + + enum Sizing: + case Absolute(pixels: Int) + case Relative(ratio: Double) + case Infer + + case class SizePx(width: Int, height: Int) + case class SizeCells(width: Int, height: Int) + + case class ImageSize(px: SizePx, cells: SizeCells) + + def fitCellsToScreen(sizeCells: SizeCells, termSize: TermCells): SizeCells = + val widthRatio = sizeCells.width.toDouble / termSize.cols + val heightRatio = sizeCells.height.toDouble / termSize.rows + if widthRatio > 1 || heightRatio > 1 then + val ratio = widthRatio max heightRatio + SizeCells((sizeCells.width / ratio).toInt, (sizeCells.height / ratio).toInt) + else sizeCells + + def calculateSize( + size: SizePx, + requestedWidth: Sizing, + requestedHeight: Sizing, + keepAspect: Boolean, + termSize: TermSize, + ): ImageSize = + import Sizing.* + + def scale(dimensionPx: Int, requestedPx: Sizing): Int = + requestedPx match + case Absolute(pixels) => pixels + case Relative(ratio) => (ratio * dimensionPx).toInt + case Infer => dimensionPx + + val cell = SizePx(termSize.pixels.width / termSize.cells.cols, termSize.pixels.height / termSize.cells.rows) + + val scaledWidth = scale(size.width, requestedWidth) + val scaledHeight = scale(size.height, requestedHeight) + + val pixels = + if !keepAspect then SizePx(scaledWidth, scaledHeight) + else + val widthRatio = scaledWidth.toDouble / size.width + val heightRatio = scaledHeight.toDouble / size.height + if widthRatio < heightRatio then SizePx(scaledWidth, (size.height * widthRatio).toInt) + else SizePx((size.width * heightRatio).toInt, scaledHeight) + + val sizeCells = SizeCells(pixels.width / cell.width, pixels.height / cell.height) + + ImageSize(pixels, sizeCells) + end calculateSize + + def showImage(img: os.Path): String = showImage(img, Sizing.Infer, Sizing.Infer, true) + def showImage( + img: os.Path, + requestedWidth: Sizing, + requestedHeight: Sizing, + keepAspect: Boolean, + fitToScreen: Boolean = true, + ): String = import java.util.Base64 ( for _ <- Option.when(checkSupport())(true) termSize <- Terminal.getSize() cursorPos <- Terminal.getCursorPos() + sizeOrig <- imageSize(img) + + ImageSize(pixels, cells) = calculateSize(sizeOrig, requestedWidth, requestedHeight, keepAspect, termSize) yield + val size = + ImageSize( + pixels, + if fitToScreen then fitCellsToScreen(cells, termSize.cells) + else cells, + ) + val image = Base64.getEncoder.encodeToString(os.read.bytes(img)) + val cell = SizePx(termSize.pixels.width / termSize.cells.cols, termSize.pixels.height / termSize.cells.rows) + + val commonParams = s"s=${sizeOrig.width},v=${sizeOrig.height},c=${size.cells.width},r=${size.cells.height}" + logger.info(s"Image size (px): ${sizeOrig.width} x ${sizeOrig.height}") + logger.info(s"Cellsize (px): ${cell.width} x ${cell.height}") + logger.info(s"Preferred size (px) ${size.px}") + logger.info(s"Preferred size (Cells) ${size.cells}") + if image.length > 4096 then val chunks = image.grouped(4096).toVector - val width = termSize.cols - cursorPos.cols * 2 - s"${apc}Gf=100,t=d,m=1,a=T,c=${width};${chunks.head}${st}" + + s"${apc}Gf=100,t=d,m=1,a=T,${commonParams};${chunks.head}${st}" + chunks.tail.init.map(c => s"${apc}Gm=1;${c}${st}").mkString + s"${apc}Gm=0;${chunks.last}${st}" - else s"${apc}Gf=100,t=d,a=T;${image}${st}" + else s"${apc}Gf=100,t=d,a=T,${commonParams};${image}${st}" ).getOrElse("Could not show image") + end showImage trait Param: def code: String diff --git a/copret/src/terminal.scala b/copret/src/terminal.scala index 75a98ba..044dcad 100644 --- a/copret/src/terminal.scala +++ b/copret/src/terminal.scala @@ -37,13 +37,10 @@ object Terminal: 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")) - logger.info(s"Querying terminal: ${query.replaceAllLiterally("\u001b", "")}") ttyOut.print(query) var response = scala.collection.mutable.ArrayBuffer[Int]() response += ttyIn.read() - logger.info(s"Response: ${response}") while (ttyIn.available > 0 && response.last != '\u0007') - logger.info(s"Response(cont): ${response}") response += ttyIn.read() new String(response.toArray, 0, response.length) @@ -104,20 +101,27 @@ object Terminal: case CursorPosResponse(rows, cols) => Some(CursorPos(cols.toInt, rows.toInt)) case _ => None - def getSize(): Option[CellsSize] = + def getSizeCells(): Option[TermCells] = queryTerm(s"${csi}s${csi}999;999H${csi}6n${csi}u") match - case CursorPosResponse(rows, cols) => Some(CellsSize(cols.toInt, rows.toInt)) + case CursorPosResponse(rows, cols) => Some(TermCells(cols.toInt, rows.toInt)) case _ => None private val SizeResponse = """\u001b\[4;(\d+);(\d+)t""".r - case class PixelSize(width: Int, height: Int) - case class CellsSize(cols: Int, rows: Int) + case class TermPixels(width: Int, height: Int) + case class TermCells(cols: Int, rows: Int) + case class TermSize(pixels: TermPixels, cells: TermCells) case class CursorPos(cols: Int, rows: Int) - def getSizePixels: Option[PixelSize] = queryTerm(s"${csi}14t") match - case SizeResponse(rows, cols) => Some(PixelSize(cols.toInt, rows.toInt)) + def getSizePixels(): Option[TermPixels] = queryTerm(s"${csi}14t") match + case SizeResponse(rows, cols) => Some(TermPixels(cols.toInt, rows.toInt)) case _ => None + def getSize(): Option[TermSize] = + for + pixels <- getSizePixels() + cells <- getSizeCells() + yield TermSize(pixels, cells) + end Terminal private[copret] trait TerminalSyntax: