Some image protocol improvements

This commit is contained in:
Alexander Gehrke 2025-01-09 17:58:26 +01:00
parent 0bcc8bc656
commit e19fe276c4
4 changed files with 105 additions and 17 deletions

View file

@ -4,7 +4,8 @@
//> using publish.organization de.qwertyuiop //> using publish.organization de.qwertyuiop
//> using publish.name copret //> using publish.name copret
//> using publish.version 0.0.2 //> using publish.version 0.0.2
//> using dep org.typelevel::cats-core:2.10.0 //> using dep org.typelevel::cats-core:2.12.0
//> using dep org.jline:jline:3.26.1 //> using dep org.jline:jline:3.28.0
//> using dep com.lihaoyi::os-lib:0.10.1 //> using dep com.lihaoyi::os-lib:0.11.3
//> using dep com.lihaoyi::fansi:0.5.0 //> using dep com.lihaoyi::fansi:0.5.0
//> using test.dep com.lihaoyi::utest:0.8.4

View file

@ -96,7 +96,8 @@ case class Presentation(
case incMd @ IncludeMarkdown(_) => println(incMd.markdownBlock()) case incMd @ IncludeMarkdown(_) => println(incMd.markdownBlock())
case Image(file, None) => println(KittyGraphicsProtocol.showImage(file)) case Image(file, None) => println(KittyGraphicsProtocol.showImage(file))
case Image(file, Some(ImageSize(w, h, aspect))) => 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 cmd: TypedCommand[_] => cmd.show()
case Silent(actions) => actions() case Silent(actions) => actions()
case Group(slides) => slides.foreach(executeSlide(pos)) case Group(slides) => slides.foreach(executeSlide(pos))

View file

@ -1,6 +1,8 @@
package de.qwertyuiop.copret package de.qwertyuiop.copret
import Terminal.* import Terminal.*
import scala.util.Try
import javax.imageio.ImageIO
object KittyGraphicsProtocol: object KittyGraphicsProtocol:
val MaxID = 4294967295L // max 32-bit unsigned 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") 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}") .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 import java.util.Base64
( (
for for
_ <- Option.when(checkSupport())(true) _ <- Option.when(checkSupport())(true)
termSize <- Terminal.getSize() termSize <- Terminal.getSize()
cursorPos <- Terminal.getCursorPos() cursorPos <- Terminal.getCursorPos()
sizeOrig <- imageSize(img)
ImageSize(pixels, cells) = calculateSize(sizeOrig, requestedWidth, requestedHeight, keepAspect, termSize)
yield yield
val size =
ImageSize(
pixels,
if fitToScreen then fitCellsToScreen(cells, termSize.cells)
else cells,
)
val image = Base64.getEncoder.encodeToString(os.read.bytes(img)) 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 if image.length > 4096 then
val chunks = image.grouped(4096).toVector 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 + chunks.tail.init.map(c => s"${apc}Gm=1;${c}${st}").mkString +
s"${apc}Gm=0;${chunks.last}${st}" 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") ).getOrElse("Could not show image")
end showImage
trait Param: trait Param:
def code: String def code: String

View file

@ -37,13 +37,10 @@ object Terminal:
def queryTerm(query: String): String = def queryTerm(query: String): String =
val ttyIn = new java.io.FileInputStream("/dev/tty") val ttyIn = new java.io.FileInputStream("/dev/tty")
val ttyOut = new java.io.PrintStream(new java.io.FileOutputStream("/dev/tty")) val ttyOut = new java.io.PrintStream(new java.io.FileOutputStream("/dev/tty"))
logger.info(s"Querying terminal: ${query.replaceAllLiterally("\u001b", "<ESC>")}")
ttyOut.print(query) ttyOut.print(query)
var response = scala.collection.mutable.ArrayBuffer[Int]() var response = scala.collection.mutable.ArrayBuffer[Int]()
response += ttyIn.read() response += ttyIn.read()
logger.info(s"Response: ${response}")
while (ttyIn.available > 0 && response.last != '\u0007') while (ttyIn.available > 0 && response.last != '\u0007')
logger.info(s"Response(cont): ${response}")
response += ttyIn.read() response += ttyIn.read()
new String(response.toArray, 0, response.length) new String(response.toArray, 0, response.length)
@ -104,20 +101,27 @@ object Terminal:
case CursorPosResponse(rows, cols) => Some(CursorPos(cols.toInt, rows.toInt)) case CursorPosResponse(rows, cols) => Some(CursorPos(cols.toInt, rows.toInt))
case _ => None case _ => None
def getSize(): Option[CellsSize] = def getSizeCells(): Option[TermCells] =
queryTerm(s"${csi}s${csi}999;999H${csi}6n${csi}u") match 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 case _ => None
private val SizeResponse = """\u001b\[4;(\d+);(\d+)t""".r private val SizeResponse = """\u001b\[4;(\d+);(\d+)t""".r
case class PixelSize(width: Int, height: Int) case class TermPixels(width: Int, height: Int)
case class CellsSize(cols: Int, rows: Int) case class TermCells(cols: Int, rows: Int)
case class TermSize(pixels: TermPixels, cells: TermCells)
case class CursorPos(cols: Int, rows: Int) case class CursorPos(cols: Int, rows: Int)
def getSizePixels: Option[PixelSize] = queryTerm(s"${csi}14t") match def getSizePixels(): Option[TermPixels] = queryTerm(s"${csi}14t") match
case SizeResponse(rows, cols) => Some(PixelSize(cols.toInt, rows.toInt)) case SizeResponse(rows, cols) => Some(TermPixels(cols.toInt, rows.toInt))
case _ => None case _ => None
def getSize(): Option[TermSize] =
for
pixels <- getSizePixels()
cells <- getSizeCells()
yield TermSize(pixels, cells)
end Terminal end Terminal
private[copret] trait TerminalSyntax: private[copret] trait TerminalSyntax: