initial commit

This commit is contained in:
2026-09-30 10:22:42 +02:00
commit 3b0f5e365f
32 changed files with 4498 additions and 0 deletions
+477
View File
@@ -0,0 +1,477 @@
// 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)
}