Files
printer-driver/emulator/internal/gui/gui.go
T
2026-09-30 10:22:42 +02:00

478 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}