478 lines
14 KiB
Go
478 lines
14 KiB
Go
// Package gui muestra la impresora emulada: el papel que sale, los
|
||
// interruptores de estado físico y el registro de bytes recibidos.
|
||
package gui
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"os"
|
||
|
||
"gioui.org/app"
|
||
"gioui.org/font"
|
||
"gioui.org/font/gofont"
|
||
"gioui.org/layout"
|
||
"gioui.org/op"
|
||
"gioui.org/op/clip"
|
||
"gioui.org/op/paint"
|
||
"gioui.org/text"
|
||
"gioui.org/unit"
|
||
"gioui.org/widget"
|
||
"gioui.org/widget/material"
|
||
|
||
"escpos-emulator/internal/escpos"
|
||
)
|
||
|
||
type (
|
||
C = layout.Context
|
||
D = layout.Dimensions
|
||
)
|
||
|
||
// Info describe por dónde se expone la impresora.
|
||
type Info struct {
|
||
LAN string
|
||
USBIP string
|
||
BusID string
|
||
}
|
||
|
||
var (
|
||
colSide = rgb(0xf3f4f6)
|
||
colDesk = rgb(0x3a3f47)
|
||
colLogBg = rgb(0xffffff)
|
||
colMuted = rgb(0x6b7280)
|
||
colDanger = rgb(0xc62828)
|
||
colOK = rgb(0x2e7d32)
|
||
colText = rgb(0x1b5e20)
|
||
colReply = rgb(0x1565c0)
|
||
colWarn = rgb(0xb45309)
|
||
colEvent = rgb(0x6b7280)
|
||
colCmd = rgb(0x111827)
|
||
colCutMark = rgb(0xaab0b8)
|
||
colHead = rgb(0x22262b)
|
||
)
|
||
|
||
var zoomLevels = []float32{0.5, 0.75, 1, 1.25, 1.5, 2}
|
||
|
||
type ui struct {
|
||
p *escpos.Printer
|
||
info Info
|
||
w *app.Window
|
||
th *material.Theme
|
||
|
||
coverOpen, paperEnd, nearEnd, cutterErr, offline widget.Bool
|
||
|
||
closeDrawer, tear, powerCycle, clearTickets, savePNG widget.Clickable
|
||
saveCapture, clearCapture, zoomIn, zoomOut widget.Clickable
|
||
|
||
side, paperList, logList widget.List
|
||
|
||
ticketOps map[*escpos.Ticket]paint.ImageOp
|
||
curImg *image.Gray
|
||
curOp paint.ImageOp
|
||
zoom int
|
||
message string
|
||
}
|
||
|
||
// Run abre la ventana y no retorna: al cerrarla termina el proceso.
|
||
func Run(p *escpos.Printer, info Info) {
|
||
th := material.NewTheme()
|
||
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
|
||
th.TextSize = 14
|
||
|
||
u := &ui{p: p, info: info, th: th, ticketOps: map[*escpos.Ticket]paint.ImageOp{}, zoom: 2}
|
||
u.side.Axis = layout.Vertical
|
||
u.paperList.Axis = layout.Vertical
|
||
u.paperList.ScrollToEnd = true
|
||
u.logList.Axis = layout.Vertical
|
||
u.logList.ScrollToEnd = true
|
||
|
||
u.w = new(app.Window)
|
||
u.w.Option(app.Title("Emulador ESC/POS — "+p.Profile().Name), app.Size(unit.Dp(1400), unit.Dp(900)))
|
||
p.SetOnChange(u.w.Invalidate)
|
||
|
||
go func() {
|
||
var ops op.Ops
|
||
for {
|
||
switch e := u.w.Event().(type) {
|
||
case app.DestroyEvent:
|
||
if e.Err != nil {
|
||
fmt.Fprintln(os.Stderr, e.Err)
|
||
os.Exit(1)
|
||
}
|
||
os.Exit(0)
|
||
case app.FrameEvent:
|
||
gtx := app.NewContext(&ops, e)
|
||
u.layout(gtx)
|
||
e.Frame(gtx.Ops)
|
||
}
|
||
}
|
||
}()
|
||
app.Main()
|
||
}
|
||
|
||
func (u *ui) handleEvents(gtx C) {
|
||
toggle := func(b *widget.Bool, set func(*escpos.Status, bool)) {
|
||
if b.Update(gtx) {
|
||
v := b.Value
|
||
u.p.UpdateStatus(func(s *escpos.Status) { set(s, v) })
|
||
}
|
||
}
|
||
toggle(&u.coverOpen, func(s *escpos.Status, v bool) { s.CoverOpen = v })
|
||
toggle(&u.paperEnd, func(s *escpos.Status, v bool) { s.PaperEnd = v })
|
||
toggle(&u.nearEnd, func(s *escpos.Status, v bool) { s.PaperNearEnd = v })
|
||
toggle(&u.cutterErr, func(s *escpos.Status, v bool) { s.CutterError = v })
|
||
toggle(&u.offline, func(s *escpos.Status, v bool) { s.Offline = v })
|
||
|
||
if u.closeDrawer.Clicked(gtx) {
|
||
u.p.UpdateStatus(func(s *escpos.Status) { s.DrawerOpen = false })
|
||
}
|
||
if u.tear.Clicked(gtx) {
|
||
u.p.TearPaper()
|
||
}
|
||
if u.powerCycle.Clicked(gtx) {
|
||
u.p.PowerCycle()
|
||
}
|
||
if u.clearTickets.Clicked(gtx) {
|
||
u.p.ClearTickets()
|
||
u.ticketOps = map[*escpos.Ticket]paint.ImageOp{}
|
||
}
|
||
if u.savePNG.Clicked(gtx) {
|
||
tix := u.p.Tickets()
|
||
var last string
|
||
for _, t := range tix {
|
||
path, err := escpos.SaveTicketPNG("tickets", t)
|
||
if err != nil {
|
||
u.message = "Error: " + err.Error()
|
||
return
|
||
}
|
||
last = path
|
||
}
|
||
if len(tix) == 0 {
|
||
u.message = "No hay tickets cortados que guardar"
|
||
} else {
|
||
u.message = fmt.Sprintf("%d tickets guardados (último: %s)", len(tix), last)
|
||
}
|
||
}
|
||
if u.saveCapture.Clicked(gtx) {
|
||
path, n, err := u.p.SaveCapture("capturas")
|
||
if err != nil {
|
||
u.message = "Error: " + err.Error()
|
||
} else {
|
||
u.message = fmt.Sprintf("%d bytes guardados en %s", n, path)
|
||
}
|
||
}
|
||
if u.clearCapture.Clicked(gtx) {
|
||
u.p.ClearCapture()
|
||
u.message = "Captura vaciada"
|
||
}
|
||
if u.zoomIn.Clicked(gtx) && u.zoom < len(zoomLevels)-1 {
|
||
u.zoom++
|
||
}
|
||
if u.zoomOut.Clicked(gtx) && u.zoom > 0 {
|
||
u.zoom--
|
||
}
|
||
}
|
||
|
||
func (u *ui) layout(gtx C) D {
|
||
u.handleEvents(gtx)
|
||
snap := u.p.Snapshot(600)
|
||
|
||
u.coverOpen.Value = snap.Status.CoverOpen
|
||
u.paperEnd.Value = snap.Status.PaperEnd
|
||
u.nearEnd.Value = snap.Status.PaperNearEnd
|
||
u.cutterErr.Value = snap.Status.CutterError
|
||
u.offline.Value = snap.Status.Offline
|
||
|
||
if len(u.ticketOps) > len(snap.Tickets) {
|
||
u.ticketOps = map[*escpos.Ticket]paint.ImageOp{}
|
||
}
|
||
|
||
return layout.Flex{}.Layout(gtx,
|
||
layout.Rigid(func(gtx C) D {
|
||
gtx.Constraints.Min.X = gtx.Dp(320)
|
||
gtx.Constraints.Max.X = gtx.Constraints.Min.X
|
||
return pane(gtx, colSide, func(gtx C) D { return u.sidebar(gtx, snap) })
|
||
}),
|
||
layout.Flexed(0.55, func(gtx C) D {
|
||
return pane(gtx, colDesk, func(gtx C) D { return u.paperView(gtx, snap) })
|
||
}),
|
||
layout.Flexed(0.45, func(gtx C) D {
|
||
return pane(gtx, colLogBg, func(gtx C) D { return u.logView(gtx, snap) })
|
||
}),
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Panel lateral
|
||
|
||
func (u *ui) sidebar(gtx C, snap escpos.Snapshot) D {
|
||
prof := snap.Profile
|
||
st := snap.Status
|
||
var items []layout.Widget
|
||
add := func(w ...layout.Widget) { items = append(items, w...) }
|
||
|
||
add(u.label(unit.Sp(20), "Emulador ESC/POS", colCmd, font.Bold))
|
||
add(u.label(unit.Sp(13), prof.Name, colMuted, font.Normal))
|
||
cutter := "sin cuchilla"
|
||
if prof.HasCutter {
|
||
cutter = fmt.Sprintf("cuchilla a %d mm del cabezal", prof.CutterOffset/8)
|
||
}
|
||
add(u.label(unit.Sp(13), fmt.Sprintf("Papel %d mm · %d puntos imprimibles · %s", prof.PaperDots/8, prof.WidthDots, cutter), colMuted, font.Normal))
|
||
|
||
add(u.section("Conexiones"))
|
||
if u.info.LAN != "" {
|
||
add(u.label(unit.Sp(13), "LAN (TCP RAW): "+u.info.LAN, colCmd, font.Normal))
|
||
}
|
||
if u.info.USBIP != "" {
|
||
add(u.label(unit.Sp(13), fmt.Sprintf("USB/IP: %s · busid %s · %04X:%04X", u.info.USBIP, u.info.BusID, prof.USBVendorID, prof.USBProductID), colCmd, font.Normal))
|
||
}
|
||
if len(snap.Connections) == 0 {
|
||
add(u.label(unit.Sp(13), "Ninguna conexión activa", colMuted, font.Normal))
|
||
}
|
||
for _, c := range snap.Connections {
|
||
add(u.label(unit.Sp(13), "● "+c, colOK, font.Normal))
|
||
}
|
||
|
||
add(u.section("Estado físico"))
|
||
add(u.check(&u.coverOpen, "Tapa abierta"))
|
||
add(u.check(&u.paperEnd, "Sin papel"))
|
||
add(u.check(&u.nearEnd, "Papel casi agotado"))
|
||
add(u.check(&u.cutterErr, "Error de cuchilla"))
|
||
add(u.check(&u.offline, "Offline (pausa manual)"))
|
||
if st.DrawerOpen {
|
||
add(u.label(unit.Sp(14), "Cajón: ABIERTO", colDanger, font.Bold))
|
||
add(u.buttons(btn{&u.closeDrawer, "Cerrar cajón"}))
|
||
} else {
|
||
add(u.label(unit.Sp(14), "Cajón: cerrado", colMuted, font.Normal))
|
||
}
|
||
if st.Blocked() {
|
||
add(u.label(unit.Sp(14), fmt.Sprintf("IMPRESORA DETENIDA · %d bytes en el buffer", snap.PendingBytes), colDanger, font.Bold))
|
||
} else if snap.PendingBytes > 0 {
|
||
add(u.label(unit.Sp(13), fmt.Sprintf("Esperando el resto de un comando: %d bytes", snap.PendingBytes), colWarn, font.Normal))
|
||
}
|
||
|
||
add(u.section("Modo de impresión actual"))
|
||
add(u.label(unit.Sp(13), snap.Mode, colCmd, font.Normal))
|
||
|
||
add(u.section("Papel"))
|
||
add(u.buttons(btn{&u.tear, "Arrancar papel"}, btn{&u.clearTickets, "Borrar tickets"}))
|
||
add(u.buttons(btn{&u.savePNG, "Guardar PNG"}, btn{&u.zoomOut, "−"}, btn{&u.zoomIn, "+"}))
|
||
add(u.label(unit.Sp(12), fmt.Sprintf("Zoom %.0f%% · %d tickets cortados", zoomLevels[u.zoom]*100, len(snap.Tickets)), colMuted, font.Normal))
|
||
|
||
add(u.section("Captura de bytes"))
|
||
add(u.label(unit.Sp(13), fmt.Sprintf("%d bytes recibidos desde el último vaciado", snap.CaptureBytes), colCmd, font.Normal))
|
||
add(u.buttons(btn{&u.saveCapture, "Guardar .bin"}, btn{&u.clearCapture, "Vaciar"}))
|
||
|
||
add(u.section("Alimentación"))
|
||
add(u.buttons(btn{&u.powerCycle, "Apagar y encender"}))
|
||
|
||
if u.message != "" {
|
||
add(u.section("Última acción"))
|
||
add(u.label(unit.Sp(12), u.message, colMuted, font.Normal))
|
||
}
|
||
|
||
return material.List(u.th, &u.side).Layout(gtx, len(items), func(gtx C, i int) D {
|
||
return layout.Inset{Left: unit.Dp(14), Right: unit.Dp(14), Top: unit.Dp(3), Bottom: unit.Dp(3)}.Layout(gtx, items[i])
|
||
})
|
||
}
|
||
|
||
func (u *ui) label(size unit.Sp, txt string, c color.NRGBA, weight font.Weight) layout.Widget {
|
||
return func(gtx C) D {
|
||
l := material.Label(u.th, size, txt)
|
||
l.Color = c
|
||
l.Font.Weight = weight
|
||
return l.Layout(gtx)
|
||
}
|
||
}
|
||
|
||
func (u *ui) section(title string) layout.Widget {
|
||
return func(gtx C) D {
|
||
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, u.label(unit.Sp(12), title, colMuted, font.Bold))
|
||
}
|
||
}
|
||
|
||
func (u *ui) check(b *widget.Bool, txt string) layout.Widget {
|
||
return func(gtx C) D {
|
||
cb := material.CheckBox(u.th, b, txt)
|
||
cb.TextSize = unit.Sp(14)
|
||
cb.Size = unit.Dp(20)
|
||
if b.Value {
|
||
cb.Color = colDanger
|
||
cb.IconColor = colDanger
|
||
}
|
||
return cb.Layout(gtx)
|
||
}
|
||
}
|
||
|
||
type btn struct {
|
||
c *widget.Clickable
|
||
label string
|
||
}
|
||
|
||
func (u *ui) buttons(bs ...btn) layout.Widget {
|
||
return func(gtx C) D {
|
||
children := make([]layout.FlexChild, 0, len(bs))
|
||
for _, b := range bs {
|
||
children = append(children, layout.Rigid(func(gtx C) D {
|
||
return layout.Inset{Right: unit.Dp(6), Top: unit.Dp(2)}.Layout(gtx, func(gtx C) D {
|
||
bt := material.Button(u.th, b.c, b.label)
|
||
bt.TextSize = unit.Sp(13)
|
||
bt.Inset = layout.Inset{Top: unit.Dp(6), Bottom: unit.Dp(6), Left: unit.Dp(10), Right: unit.Dp(10)}
|
||
return bt.Layout(gtx)
|
||
})
|
||
}))
|
||
}
|
||
return layout.Flex{}.Layout(gtx, children...)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Papel
|
||
|
||
func (u *ui) scale(gtx C) float32 { return zoomLevels[u.zoom] / gtx.Metric.PxPerDp }
|
||
|
||
func (u *ui) paperView(gtx C, snap escpos.Snapshot) D {
|
||
n := len(snap.Tickets) + 1
|
||
return material.List(u.th, &u.paperList).Layout(gtx, n, func(gtx C, i int) D {
|
||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||
if i < len(snap.Tickets) {
|
||
return u.ticketItem(gtx, snap.Tickets[i])
|
||
}
|
||
return u.currentPaper(gtx, snap)
|
||
})
|
||
}
|
||
|
||
func (u *ui) ticketItem(gtx C, t *escpos.Ticket) D {
|
||
imgOp, ok := u.ticketOps[t]
|
||
if !ok {
|
||
imgOp = paint.NewImageOp(t.Image)
|
||
imgOp.Filter = paint.FilterNearest
|
||
u.ticketOps[t] = imgOp
|
||
}
|
||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||
layout.Rigid(func(gtx C) D {
|
||
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
|
||
return center(gtx, widget.Image{Src: imgOp, Scale: u.scale(gtx)}.Layout)
|
||
})
|
||
}),
|
||
layout.Rigid(func(gtx C) D {
|
||
txt := fmt.Sprintf("✂ ─ ─ ─ corte %s · ticket #%d · %s ─ ─ ─", t.Cut, t.Seq, t.Time.Format("15:04:05"))
|
||
return center(gtx, u.label(unit.Sp(12), txt, colCutMark, font.Normal))
|
||
}),
|
||
)
|
||
}
|
||
|
||
func (u *ui) currentPaper(gtx C, snap escpos.Snapshot) D {
|
||
paperW := gtx.Dp(unit.Dp(float32(snap.Profile.PaperDots) * u.scale(gtx)))
|
||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||
layout.Rigid(func(gtx C) D {
|
||
return layout.Inset{Top: unit.Dp(24)}.Layout(gtx, func(gtx C) D {
|
||
return center(gtx, u.label(unit.Sp(12), "Papel dentro de la impresora (aún sin cortar)", colCutMark, font.Normal))
|
||
})
|
||
}),
|
||
layout.Rigid(func(gtx C) D {
|
||
if snap.Paper == nil {
|
||
return D{}
|
||
}
|
||
if snap.Paper != u.curImg {
|
||
u.curImg = snap.Paper
|
||
u.curOp = paint.NewImageOp(snap.Paper)
|
||
u.curOp.Filter = paint.FilterNearest
|
||
}
|
||
return center(gtx, func(gtx C) D {
|
||
dims := widget.Image{Src: u.curOp, Scale: u.scale(gtx)}.Layout(gtx)
|
||
if snap.CutterRow > 0 {
|
||
y := snap.CutterRow * dims.Size.Y / snap.Paper.Bounds().Dy()
|
||
st := op.Offset(image.Pt(0, y)).Push(gtx.Ops)
|
||
paint.FillShape(gtx.Ops, colDanger, clip.Rect{Max: image.Pt(dims.Size.X, max(1, gtx.Dp(1)))}.Op())
|
||
st.Pop()
|
||
}
|
||
return dims
|
||
})
|
||
}),
|
||
layout.Rigid(func(gtx C) D {
|
||
return center(gtx, func(gtx C) D {
|
||
sz := image.Pt(paperW+gtx.Dp(40), gtx.Dp(34))
|
||
paint.FillShape(gtx.Ops, colHead, clip.UniformRRect(image.Rectangle{Max: sz}, gtx.Dp(4)).Op(gtx.Ops))
|
||
st := op.Offset(image.Pt(gtx.Dp(12), gtx.Dp(8))).Push(gtx.Ops)
|
||
msg := "▲ cabezal térmico"
|
||
if snap.Profile.HasCutter {
|
||
msg += " · la línea roja marca la cuchilla"
|
||
}
|
||
u.label(unit.Sp(12), msg, rgb(0xd1d5db), font.Normal)(gtx)
|
||
st.Pop()
|
||
return D{Size: sz}
|
||
})
|
||
}),
|
||
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Registro
|
||
|
||
func kindColor(k escpos.LogKind) color.NRGBA {
|
||
switch k {
|
||
case escpos.LogText:
|
||
return colText
|
||
case escpos.LogResponse:
|
||
return colReply
|
||
case escpos.LogWarning:
|
||
return colWarn
|
||
case escpos.LogEvent:
|
||
return colEvent
|
||
}
|
||
return colCmd
|
||
}
|
||
|
||
func (u *ui) logView(gtx C, snap escpos.Snapshot) D {
|
||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||
layout.Rigid(func(gtx C) D {
|
||
return layout.Inset{Left: unit.Dp(10), Top: unit.Dp(10), Bottom: unit.Dp(6)}.Layout(gtx,
|
||
u.label(unit.Sp(14), "Registro: bytes recibidos y su significado", colCmd, font.Bold))
|
||
}),
|
||
layout.Flexed(1, func(gtx C) D {
|
||
return material.List(u.th, &u.logList).Layout(gtx, len(snap.Log), func(gtx C, i int) D {
|
||
e := snap.Log[i]
|
||
return layout.Inset{Left: unit.Dp(10), Right: unit.Dp(10), Top: unit.Dp(3), Bottom: unit.Dp(3)}.Layout(gtx, func(gtx C) D {
|
||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||
layout.Rigid(func(gtx C) D {
|
||
head := fmt.Sprintf("%s [%s] %s", e.Time.Format("15:04:05.000"), e.Source, e.Text)
|
||
return u.label(unit.Sp(13), head, kindColor(e.Kind), font.Normal)(gtx)
|
||
}),
|
||
layout.Rigid(func(gtx C) D {
|
||
if e.Hex == "" {
|
||
return D{}
|
||
}
|
||
l := material.Label(u.th, unit.Sp(12), e.Hex)
|
||
l.Font.Typeface = "Go Mono"
|
||
l.Color = colMuted
|
||
return l.Layout(gtx)
|
||
}),
|
||
)
|
||
})
|
||
})
|
||
}),
|
||
)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func pane(gtx C, c color.NRGBA, w layout.Widget) D {
|
||
size := gtx.Constraints.Max
|
||
paint.FillShape(gtx.Ops, c, clip.Rect{Max: size}.Op())
|
||
gtx.Constraints.Min = size
|
||
w(gtx)
|
||
return D{Size: size}
|
||
}
|
||
|
||
func rgb(v uint32) color.NRGBA {
|
||
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}
|
||
}
|
||
|
||
// center centra w horizontalmente en todo el ancho disponible.
|
||
func center(gtx C, w layout.Widget) D {
|
||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||
return layout.Center.Layout(gtx, w)
|
||
}
|