// 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) }