mirror of
https://github.com/BigJk/crt.git
synced 2026-02-06 10:47:25 +00:00
619 lines
15 KiB
Go
619 lines
15 KiB
Go
package crt
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/BigJk/crt/shader"
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
"github.com/hajimehoshi/ebiten/v2/text"
|
|
"github.com/lucasb-eyer/go-colorful"
|
|
"github.com/muesli/ansi"
|
|
"github.com/muesli/termenv"
|
|
"image"
|
|
"image/color"
|
|
"io"
|
|
"sync"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// colorCache is the ansi color cache.
|
|
var colorCache = map[int]color.Color{}
|
|
|
|
type Window struct {
|
|
sync.Mutex
|
|
|
|
// Terminal dimensions and grid.
|
|
grid [][]GridCell
|
|
cellsWidth int
|
|
cellsHeight int
|
|
cellWidth int
|
|
cellHeight int
|
|
cellOffsetY int
|
|
|
|
// Input and output.
|
|
inputAdapter InputAdapter
|
|
tty io.Reader
|
|
|
|
// Terminal cursor and color states.
|
|
cursorChar string
|
|
cursorColor color.Color
|
|
showCursor bool
|
|
cursorX int
|
|
cursorY int
|
|
mouseCellX int
|
|
mouseCellY int
|
|
defaultBg color.Color
|
|
curFg color.Color
|
|
curBg color.Color
|
|
curWeight FontWeight
|
|
|
|
// Callbacks
|
|
onUpdate func()
|
|
onPreDraw func(screen *ebiten.Image)
|
|
onPostDraw func(screen *ebiten.Image)
|
|
|
|
// Other
|
|
seqBuffer []byte
|
|
showTps bool
|
|
fonts Fonts
|
|
bgColors *image.RGBA
|
|
shader []shader.Shader
|
|
routine sync.Once
|
|
lastBuffer *ebiten.Image
|
|
invalidateBuffer bool
|
|
}
|
|
|
|
// NewGame creates a new terminal game with the given dimensions and font faces.
|
|
func NewGame(width int, height int, fonts Fonts, tty io.Reader, adapter InputAdapter, defaultBg color.Color) (*Window, error) {
|
|
if defaultBg == nil {
|
|
defaultBg = color.Black
|
|
}
|
|
|
|
bounds, _, _ := fonts.Normal.GlyphBounds([]rune("█")[0])
|
|
size := bounds.Max.Sub(bounds.Min)
|
|
|
|
cellWidth := size.X.Round()
|
|
cellHeight := size.Y.Round()
|
|
cellOffsetY := -bounds.Min.Y.Round()
|
|
|
|
cellsWidth := width / cellWidth
|
|
cellsHeight := height / cellHeight
|
|
|
|
grid := make([][]GridCell, cellsHeight)
|
|
for y := 0; y < cellsHeight; y++ {
|
|
grid[y] = make([]GridCell, cellsWidth)
|
|
for x := 0; x < cellsWidth; x++ {
|
|
grid[y][x] = GridCell{
|
|
Char: ' ',
|
|
Fg: color.White,
|
|
Bg: defaultBg,
|
|
Weight: FontWeightNormal,
|
|
}
|
|
}
|
|
}
|
|
|
|
game := &Window{
|
|
inputAdapter: adapter,
|
|
cellsWidth: cellsWidth,
|
|
cellsHeight: cellsHeight,
|
|
cellWidth: cellWidth,
|
|
cellHeight: cellHeight,
|
|
cellOffsetY: cellOffsetY,
|
|
fonts: fonts,
|
|
defaultBg: defaultBg,
|
|
grid: grid,
|
|
tty: tty,
|
|
bgColors: image.NewRGBA(image.Rect(0, 0, cellsWidth*cellWidth, cellsHeight*cellHeight)),
|
|
cursorChar: "█",
|
|
cursorColor: color.RGBA{R: 255, G: 255, B: 255, A: 100},
|
|
onUpdate: func() {},
|
|
onPreDraw: func(screen *ebiten.Image) {},
|
|
onPostDraw: func(screen *ebiten.Image) {},
|
|
invalidateBuffer: true,
|
|
seqBuffer: make([]byte, 0, 2^12),
|
|
}
|
|
|
|
game.inputAdapter.HandleWindowSize(WindowSize{
|
|
Width: cellsWidth - 1,
|
|
Height: cellsHeight,
|
|
})
|
|
|
|
game.ResetSGR()
|
|
game.RecalculateBackgrounds()
|
|
|
|
return game, nil
|
|
}
|
|
|
|
// SetShowCursor enables or disables the cursor.
|
|
func (g *Window) SetShowCursor(val bool) {
|
|
g.showCursor = val
|
|
g.InvalidateBuffer()
|
|
}
|
|
|
|
// SetCursorChar sets the character that is used for the cursor.
|
|
func (g *Window) SetCursorChar(char string) {
|
|
g.cursorChar = char
|
|
g.InvalidateBuffer()
|
|
}
|
|
|
|
// SetCursorColor sets the color of the cursor.
|
|
func (g *Window) SetCursorColor(color color.Color) {
|
|
g.cursorColor = color
|
|
g.InvalidateBuffer()
|
|
}
|
|
|
|
// SetShader sets a shader that is applied to the whole screen.
|
|
func (g *Window) SetShader(shader ...shader.Shader) {
|
|
g.shader = shader
|
|
}
|
|
|
|
// SetOnUpdate sets a function that is called every frame.
|
|
func (g *Window) SetOnUpdate(fn func()) {
|
|
g.onUpdate = fn
|
|
}
|
|
|
|
// SetOnPreDraw sets a function that is called before the screen is drawn.
|
|
func (g *Window) SetOnPreDraw(fn func(screen *ebiten.Image)) {
|
|
g.onPreDraw = fn
|
|
}
|
|
|
|
// SetOnPostDraw sets a function that is called after the screen is drawn.
|
|
func (g *Window) SetOnPostDraw(fn func(screen *ebiten.Image)) {
|
|
g.onPostDraw = fn
|
|
}
|
|
|
|
// ShowTPS enables or disables the TPS counter on the top left.
|
|
func (g *Window) ShowTPS(val bool) {
|
|
g.showTps = val
|
|
}
|
|
|
|
// InvalidateBuffer forces the buffer to be redrawn.
|
|
func (g *Window) InvalidateBuffer() {
|
|
g.invalidateBuffer = true
|
|
}
|
|
|
|
// ResetSGR resets the SGR attributes to their default values.
|
|
func (g *Window) ResetSGR() {
|
|
g.curFg = color.White
|
|
g.curBg = g.defaultBg
|
|
g.curWeight = FontWeightNormal
|
|
}
|
|
|
|
// SetBgPixels sets a chunk of background pixels in the size of the cell.
|
|
func (g *Window) SetBgPixels(x, y int, c color.Color) {
|
|
for i := 0; i < g.cellWidth; i++ {
|
|
for j := 0; j < g.cellHeight; j++ {
|
|
g.bgColors.Set(x*g.cellWidth+i, y*g.cellHeight+j, c)
|
|
}
|
|
}
|
|
g.InvalidateBuffer()
|
|
}
|
|
|
|
// SetBg sets the background color of a cell and checks if it needs to be redrawn.
|
|
func (g *Window) SetBg(x, y int, c color.Color) {
|
|
ra, rg, rb, _ := g.grid[y][x].Bg.RGBA()
|
|
ca, cg, cb, _ := c.RGBA()
|
|
if ra == ca && rg == cg && rb == cb {
|
|
return
|
|
}
|
|
|
|
g.SetBgPixels(x, y, c)
|
|
g.grid[y][x].Bg = c
|
|
}
|
|
|
|
// GetCellsWidth returns the number of cells in the x direction.
|
|
func (g *Window) GetCellsWidth() int {
|
|
return g.cellsWidth
|
|
}
|
|
|
|
// GetCellsHeight returns the number of cells in the y direction.
|
|
func (g *Window) GetCellsHeight() int {
|
|
return g.cellsHeight
|
|
}
|
|
|
|
func (g *Window) handleCSI(csi any) {
|
|
switch seq := csi.(type) {
|
|
case CursorUpSeq:
|
|
g.cursorY -= seq.Count
|
|
if g.cursorY < 0 {
|
|
g.cursorY = 0
|
|
}
|
|
case CursorDownSeq:
|
|
g.cursorY += seq.Count
|
|
if g.cursorY >= g.cellsHeight {
|
|
g.cursorY = g.cellsHeight - 1
|
|
}
|
|
case CursorForwardSeq:
|
|
g.cursorX += seq.Count
|
|
if g.cursorX >= g.cellsWidth {
|
|
g.cursorX = g.cellsWidth - 1
|
|
}
|
|
case CursorBackSeq:
|
|
g.cursorX -= seq.Count
|
|
if g.cursorX < 0 {
|
|
g.cursorX = 0
|
|
}
|
|
case CursorNextLineSeq:
|
|
g.cursorY += seq.Count
|
|
if g.cursorY >= g.cellsHeight {
|
|
g.cursorY = g.cellsHeight - 1
|
|
}
|
|
g.cursorX = 0
|
|
case CursorPreviousLineSeq:
|
|
g.cursorY -= seq.Count
|
|
if g.cursorY < 0 {
|
|
g.cursorY = 0
|
|
}
|
|
g.cursorX = 0
|
|
case CursorHorizontalSeq:
|
|
g.cursorX = seq.Count - 1
|
|
case CursorPositionSeq:
|
|
g.cursorX = seq.Col - 1
|
|
g.cursorY = seq.Row - 1
|
|
|
|
if g.cursorX < 0 {
|
|
g.cursorX = 0
|
|
} else if g.cursorX >= g.cellsWidth {
|
|
g.cursorX = g.cellsWidth - 1
|
|
}
|
|
|
|
if g.cursorY < 0 {
|
|
g.cursorY = 0
|
|
} else if g.cursorY >= g.cellsHeight {
|
|
g.cursorY = g.cellsHeight - 1
|
|
}
|
|
case EraseDisplaySeq:
|
|
if seq.Type != 2 {
|
|
return // only support 2 (erase entire display)
|
|
}
|
|
|
|
for i := 0; i < g.cellsWidth; i++ {
|
|
for j := 0; j < g.cellsHeight; j++ {
|
|
g.grid[j][i].Char = ' '
|
|
g.grid[j][i].Fg = color.White
|
|
g.grid[j][i].Bg = g.defaultBg
|
|
}
|
|
}
|
|
case EraseLineSeq:
|
|
switch seq.Type {
|
|
case 0: // erase from cursor to end of line
|
|
for i := g.cursorX; i < g.cellsWidth-g.cursorX; i++ {
|
|
g.grid[g.cursorY][g.cursorX+i].Char = ' '
|
|
g.grid[g.cursorY][g.cursorX+i].Fg = color.White
|
|
g.SetBg(g.cursorX+i, g.cursorY, g.defaultBg)
|
|
}
|
|
case 1: // erase from start of line to cursor
|
|
for i := 0; i < g.cursorX; i++ {
|
|
g.grid[g.cursorY][i].Char = ' '
|
|
g.grid[g.cursorY][i].Fg = color.White
|
|
g.SetBg(i, g.cursorY, g.defaultBg)
|
|
}
|
|
case 2: // erase entire line
|
|
for i := 0; i < g.cellsWidth; i++ {
|
|
g.grid[g.cursorY][i].Char = ' '
|
|
g.grid[g.cursorY][i].Fg = color.White
|
|
g.SetBg(i, g.cursorY, g.defaultBg)
|
|
}
|
|
}
|
|
case CursorShowSeq:
|
|
g.SetShowCursor(true)
|
|
case CursorHideSeq:
|
|
g.SetShowCursor(false)
|
|
case ScrollUpSeq:
|
|
fmt.Println("UNSUPPORTED: ScrollUpSeq", seq.Count)
|
|
case ScrollDownSeq:
|
|
fmt.Println("UNSUPPORTED: ScrollDownSeq", seq.Count)
|
|
case SaveCursorPositionSeq:
|
|
fmt.Println("UNSUPPORTED: SaveCursorPositionSeq")
|
|
case RestoreCursorPositionSeq:
|
|
fmt.Println("UNSUPPORTED: RestoreCursorPositionSeq")
|
|
case ChangeScrollingRegionSeq:
|
|
fmt.Println("UNSUPPORTED: ChangeScrollingRegionSeq")
|
|
case InsertLineSeq:
|
|
fmt.Println("UNSUPPORTED: InsertLineSeq")
|
|
case DeleteLineSeq:
|
|
fmt.Println("UNSUPPORTED: DeleteLineSeq")
|
|
}
|
|
}
|
|
|
|
func (g *Window) handleSGR(sgr any) {
|
|
switch seq := sgr.(type) {
|
|
case SGRReset:
|
|
g.ResetSGR()
|
|
case SGRBold:
|
|
g.curWeight = FontWeightBold
|
|
case SGRItalic:
|
|
g.curWeight = FontWeightItalic
|
|
case SGRUnsetBold:
|
|
g.curWeight = FontWeightNormal
|
|
case SGRUnsetItalic:
|
|
g.curWeight = FontWeightNormal
|
|
case SGRFgTrueColor:
|
|
g.curFg = color.RGBA{R: seq.R, G: seq.G, B: seq.B, A: 255}
|
|
case SGRBgTrueColor:
|
|
g.curBg = color.RGBA{R: seq.R, G: seq.G, B: seq.B, A: 255}
|
|
case SGRFgColor:
|
|
if val, ok := colorCache[seq.Id]; ok {
|
|
g.curFg = val
|
|
} else {
|
|
if col, err := colorful.Hex(termenv.ANSI256Color(seq.Id).String()); err == nil {
|
|
g.curFg = col
|
|
colorCache[seq.Id] = col
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (g *Window) parseSequences(str string, printExtra bool) int {
|
|
lastFound := 0
|
|
for i := 0; i < len(str); i++ {
|
|
if sgr, ok := extractSGR(str[i:]); ok {
|
|
i += len(sgr) - 1
|
|
|
|
if sgr, ok := parseSGR(sgr); ok {
|
|
lastFound = i
|
|
for i := range sgr {
|
|
g.handleSGR(sgr[i])
|
|
g.InvalidateBuffer()
|
|
}
|
|
}
|
|
} else if csi, ok := extractCSI(str[i:]); ok {
|
|
i += len(csi) - 1
|
|
|
|
if csi, ok := parseCSI(csi); ok {
|
|
lastFound = i
|
|
g.handleCSI(csi)
|
|
g.InvalidateBuffer()
|
|
}
|
|
} else if printExtra {
|
|
if r, size := utf8.DecodeRuneInString(str[i:]); r != utf8.RuneError {
|
|
g.PrintChar(r, g.curFg, g.curBg, g.curWeight)
|
|
i += size - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
return lastFound
|
|
}
|
|
|
|
func (g *Window) drainSequence() {
|
|
if len(g.seqBuffer) > 0 {
|
|
g.parseSequences(string(g.seqBuffer), true)
|
|
g.seqBuffer = g.seqBuffer[:0]
|
|
}
|
|
}
|
|
|
|
// RecalculateBackgrounds syncs the background colors to the background pixels.
|
|
func (g *Window) RecalculateBackgrounds() {
|
|
for i := 0; i < g.cellsWidth; i++ {
|
|
for j := 0; j < g.cellsHeight; j++ {
|
|
g.SetBgPixels(i, j, g.grid[j][i].Bg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// PrintChar prints a character to the screen.
|
|
func (g *Window) PrintChar(r rune, fg, bg color.Color, weight FontWeight) {
|
|
if r == '\n' {
|
|
g.cursorX = 0
|
|
g.cursorY++
|
|
return
|
|
}
|
|
|
|
if ansi.PrintableRuneWidth(string(r)) == 0 {
|
|
return
|
|
}
|
|
|
|
// Wrap around if we're at the end of the line.
|
|
if g.cursorX >= g.cellsWidth {
|
|
g.cursorX = 0
|
|
g.cursorY++
|
|
}
|
|
|
|
// Scroll down if we're at the bottom and add a new line.
|
|
if g.cursorY >= g.cellsHeight {
|
|
diff := g.cursorY - g.cellsHeight + 1
|
|
g.grid = g.grid[diff:]
|
|
for i := 0; i < diff; i++ {
|
|
g.grid = append(g.grid, make([]GridCell, g.cellsWidth))
|
|
for i := 0; i < g.cellsWidth; i++ {
|
|
g.grid[len(g.grid)-1][i].Char = ' '
|
|
g.grid[len(g.grid)-1][i].Fg = color.White
|
|
g.grid[len(g.grid)-1][i].Bg = g.defaultBg
|
|
}
|
|
}
|
|
g.cursorY = g.cellsHeight - 1
|
|
g.RecalculateBackgrounds()
|
|
}
|
|
|
|
// Set the cell.
|
|
g.grid[g.cursorY][g.cursorX].Char = r
|
|
g.grid[g.cursorY][g.cursorX].Fg = fg
|
|
g.grid[g.cursorY][g.cursorX].Bg = bg
|
|
g.grid[g.cursorY][g.cursorX].Weight = weight
|
|
|
|
// Set the pixels.
|
|
g.SetBgPixels(g.cursorX, g.cursorY, g.grid[g.cursorY][g.cursorX].Bg)
|
|
|
|
// Move the cursor.
|
|
g.cursorX++
|
|
|
|
g.InvalidateBuffer()
|
|
}
|
|
|
|
func (g *Window) Update() error {
|
|
g.routine.Do(func() {
|
|
go func() {
|
|
buf := make([]byte, 1024)
|
|
for {
|
|
n, err := g.tty.Read(buf)
|
|
if err != nil {
|
|
fmt.Println("ERROR: ", err)
|
|
continue
|
|
}
|
|
|
|
if n == 0 {
|
|
continue
|
|
}
|
|
|
|
g.Lock()
|
|
{
|
|
g.seqBuffer = append(g.seqBuffer, buf[:n]...)
|
|
}
|
|
g.Unlock()
|
|
}
|
|
}()
|
|
})
|
|
|
|
mx, my := ebiten.CursorPosition()
|
|
mcx, mcy := mx/g.cellWidth, my/g.cellHeight
|
|
|
|
if mcx != g.mouseCellX || mcy != g.mouseCellY {
|
|
g.mouseCellX = mcx
|
|
g.mouseCellY = mcy
|
|
|
|
g.inputAdapter.HandleMouseMotion(MouseMotion{
|
|
X: g.mouseCellX,
|
|
Y: g.mouseCellY,
|
|
})
|
|
}
|
|
|
|
// Mouse buttons.
|
|
if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
|
|
g.inputAdapter.HandleMouseButton(MouseButton{
|
|
X: g.mouseCellX,
|
|
Y: g.mouseCellY,
|
|
Shift: ebiten.IsKeyPressed(ebiten.KeyShift),
|
|
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
|
|
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
|
|
Button: ebiten.MouseButtonLeft,
|
|
JustPressed: false,
|
|
JustReleased: true,
|
|
})
|
|
} else if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
|
|
g.inputAdapter.HandleMouseButton(MouseButton{
|
|
X: g.mouseCellX,
|
|
Y: g.mouseCellY,
|
|
Shift: ebiten.IsKeyPressed(ebiten.KeyShift),
|
|
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
|
|
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
|
|
Button: ebiten.MouseButtonLeft,
|
|
JustPressed: true,
|
|
JustReleased: false,
|
|
})
|
|
}
|
|
|
|
// Mouse wheel.
|
|
_, wy := ebiten.Wheel()
|
|
if wy > 0 || wy < 0 {
|
|
g.inputAdapter.HandleMouseWheel(MouseWheel{
|
|
X: g.mouseCellX,
|
|
Y: g.mouseCellY,
|
|
Shift: ebiten.IsKeyPressed(ebiten.KeyShift),
|
|
Alt: ebiten.IsKeyPressed(ebiten.KeyAlt),
|
|
Ctrl: ebiten.IsKeyPressed(ebiten.KeyControl),
|
|
DX: 0,
|
|
DY: wy,
|
|
})
|
|
}
|
|
|
|
// Keyboard.
|
|
g.inputAdapter.HandleKeyPress()
|
|
|
|
g.onUpdate()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Window) Draw(screen *ebiten.Image) {
|
|
g.Lock()
|
|
defer g.Unlock()
|
|
|
|
g.onPreDraw(screen)
|
|
|
|
// We process the sequence buffer here so that we don't get flickering
|
|
g.drainSequence()
|
|
|
|
screen.Fill(g.defaultBg)
|
|
|
|
// Get current buffer
|
|
bufferImage := g.lastBuffer
|
|
|
|
// Only draw the buffer if it's invalid
|
|
if bufferImage == nil || g.invalidateBuffer {
|
|
bufferImage = ebiten.NewImage(g.cellsWidth*g.cellWidth, g.cellsHeight*g.cellHeight)
|
|
|
|
// Draw background
|
|
bufferImage.WritePixels(g.bgColors.Pix)
|
|
|
|
// Draw text
|
|
for y := 0; y < g.cellsHeight; y++ {
|
|
for x := 0; x < g.cellsWidth; x++ {
|
|
if g.grid[y][x].Char == ' ' {
|
|
continue
|
|
}
|
|
|
|
switch g.grid[y][x].Weight {
|
|
case FontWeightNormal:
|
|
text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Normal, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg)
|
|
case FontWeightBold:
|
|
text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Bold, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg)
|
|
case FontWeightItalic:
|
|
text.Draw(bufferImage, string(g.grid[y][x].Char), g.fonts.Italic, x*g.cellWidth, y*g.cellHeight+g.cellOffsetY, g.grid[y][x].Fg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw cursor
|
|
if g.showCursor {
|
|
text.Draw(bufferImage, g.cursorChar, g.fonts.Normal, g.cursorX*g.cellWidth, g.cursorY*g.cellHeight+g.cellOffsetY, g.cursorColor)
|
|
}
|
|
|
|
g.lastBuffer = bufferImage
|
|
g.invalidateBuffer = false
|
|
}
|
|
|
|
// Draw shader
|
|
if g.shader != nil {
|
|
shaderBuffer := ebiten.NewImageFromImage(bufferImage)
|
|
|
|
for i := range g.shader {
|
|
_ = g.shader[i].Apply(screen, shaderBuffer)
|
|
|
|
if len(g.shader) > 0 {
|
|
shaderBuffer.DrawImage(screen, nil)
|
|
}
|
|
}
|
|
} else {
|
|
screen.DrawImage(bufferImage, nil)
|
|
}
|
|
|
|
if g.showTps {
|
|
ebitenutil.DebugPrint(screen, fmt.Sprintf("TPS: %0.2f", ebiten.CurrentTPS()))
|
|
}
|
|
|
|
g.onPostDraw(screen)
|
|
}
|
|
|
|
func (g *Window) Layout(outsideWidth, outsideHeight int) (int, int) {
|
|
return g.cellsWidth * g.cellWidth, g.cellsHeight * g.cellHeight
|
|
}
|
|
|
|
func (g *Window) Run(title string) error {
|
|
sw, sh := g.Layout(0, 0)
|
|
|
|
ebiten.SetScreenFilterEnabled(false)
|
|
ebiten.SetWindowSize(sw, sh)
|
|
ebiten.SetWindowTitle(title)
|
|
if err := ebiten.RunGame(g); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Window) Kill() {
|
|
SysKill()
|
|
}
|