caire/gui.go
2023-11-19 16:01:50 +02:00

537 lines
13 KiB
Go

package caire
import (
"fmt"
"image"
"image/color"
"image/draw"
"math"
"math/rand"
"time"
"gioui.org/app"
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/io/key"
"gioui.org/io/system"
"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"
"github.com/esimov/caire/imop"
"github.com/esimov/caire/utils"
)
const (
// The starting colors for the linear gradient, used when the image is resized both horizontally and vertically.
// In this case the preview mode is deactivated and a dynamic gradient overlay is shown.
redStart = 137
greenStart = 47
blueStart = 54
// The ending colors for the linear gradient. The starting colors and ending colors are lerped.
redEnd = 255
greenEnd = 112
blueEnd = 105
)
var (
maxScreenX float32 = 1024
maxScreenY float32 = 640
defaultBkgColor = color.Transparent
defaultFillColor = color.Black
)
type interval struct {
min, max float64
}
// Gui is the basic struct containing all of the information needed for the UI operation.
// It receives the resized image transferred through a channel which is called in a separate goroutine.
type Gui struct {
cfg struct {
x interval
y interval
chrot bool
angle float32
window struct {
w float32
h float32
title string
}
color struct {
randR uint8
randG uint8
randB uint8
background color.Color
fill color.Color
}
timeStamp time.Time
}
proc struct {
isDone bool
img image.Image
seams []Seam
wrk <-chan worker
err chan<- error
}
cp *Processor
cop *imop.Composite
bop *imop.Blend
th *material.Theme
ctx layout.Context
huds map[int]*hudCtrl
view struct {
huds layout.List
}
}
type hudCtrl struct {
visible widget.Bool
index int
title string
}
// NewGUI initializes the Gio interface.
func NewGUI(w, h int) *Gui {
defaultColor := color.NRGBA{R: 0x2d, G: 0x23, B: 0x2e, A: 0xff}
gui := &Gui{
ctx: layout.Context{
Ops: new(op.Ops),
Constraints: layout.Constraints{
Max: image.Pt(w, h),
},
},
cop: imop.InitOp(),
bop: imop.NewBlend(),
huds: make(map[int]*hudCtrl),
th: material.NewTheme(),
}
gui.th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
gui.th.TextSize = unit.Sp(12)
gui.th.Palette.ContrastBg = defaultColor
gui.th.FingerSize = 10
gui.initWindow(w, h)
return gui
}
// Add adds a new hud control for debugging.
func (g *Gui) Add(index int, title string, enabled bool) {
control := &hudCtrl{
index: index,
title: title,
visible: widget.Bool{},
}
control.visible.Value = enabled
g.huds[index] = control
}
// initWindow creates and initializes the GUI window.
func (g *Gui) initWindow(w, h int) {
rand.NewSource(time.Now().UnixNano())
g.cfg.angle = 45
g.cfg.color.randR = uint8(random(1, 2))
g.cfg.color.randG = uint8(random(1, 2))
g.cfg.color.randB = uint8(random(1, 2))
g.cfg.window.w, g.cfg.window.h = float32(w), float32(h)
g.cfg.x = interval{min: 0, max: float64(w)}
g.cfg.y = interval{min: 0, max: float64(h)}
g.cfg.color.background = defaultBkgColor
g.cfg.color.fill = defaultFillColor
if !resizeXY {
g.cfg.window.w, g.cfg.window.h = g.getWindowSize()
}
g.cfg.window.title = "Preview"
}
// getWindowSize returns the resized image dimension.
func (g *Gui) getWindowSize() (float32, float32) {
w, h := g.cfg.window.w, g.cfg.window.h
// Maintain the image aspect ratio in case the image width and height is greater than the predefined window.
r := getRatio(w, h)
if w > maxScreenX && h > maxScreenY {
w = w * r
h = h * r
}
return w, h
}
// Run is the core method of the Gio GUI application.
// This updates the window with the resized image received from a channel
// and terminates when the image resizing operation completes.
func (g *Gui) Run() error {
var (
rc uint8 = redStart
gc uint8 = greenStart
bc uint8 = blueStart
descRed, descGreen, descBlue bool
)
w := app.NewWindow(app.Title(g.cfg.window.title), app.Size(
unit.Dp(g.cfg.window.w),
unit.Dp(g.cfg.window.h),
))
w.Perform(system.ActionCenter)
g.cfg.timeStamp = time.Now()
if g.cp.Debug {
g.Add(0, "Show seams", true)
if len(g.cp.MaskPath) > 0 || len(g.cp.RMaskPath) > 0 || g.cp.FaceDetect {
g.Add(1, "Debug mask", false)
}
}
abortFn := func() {
var dx, dy int
if g.proc.img != nil {
bounds := g.proc.img.Bounds()
dx, dy = bounds.Max.X, bounds.Max.Y
}
if !g.proc.isDone {
if (g.cp.NewWidth > 0 && g.cp.NewWidth != dx) ||
(g.cp.NewHeight > 0 && g.cp.NewHeight != dy) {
errorMsg := fmt.Sprintf("%s %s %s",
utils.DecorateText("⚡ CAIRE", utils.StatusMessage),
utils.DecorateText("⇢ process aborted by the user...", utils.DefaultMessage),
utils.DecorateText("✘\n", utils.ErrorMessage),
)
g.cp.Spinner.StopMsg = errorMsg
g.cp.Spinner.Stop()
}
}
g.cp.Spinner.RestoreCursor()
}
for {
select {
case e := <-w.Events():
switch e := e.(type) {
case system.FrameEvent:
gtx := layout.NewContext(g.ctx.Ops, e)
key.InputOp{Tag: w, Keys: key.NameEscape}.Add(gtx.Ops)
for _, ev := range gtx.Queue.Events(w) {
if e, ok := ev.(key.Event); ok && e.Name == key.NameEscape {
w.Perform(system.ActionClose)
}
}
{ // red
if descRed {
rc--
} else {
rc++
}
if rc >= redEnd {
descRed = !descRed
}
if rc == redStart {
descRed = !descRed
}
}
{ // green
if descGreen {
gc--
} else {
gc++
}
if gc >= greenEnd {
descGreen = !descGreen
}
if gc == greenStart {
descGreen = !descGreen
}
}
{ // blue
if descBlue {
bc--
} else {
bc++
}
if bc >= blueEnd {
descBlue = !descBlue
}
if bc == blueStart {
descBlue = !descBlue
}
}
g.draw(gtx, color.NRGBA{R: rc, G: gc, B: bc})
e.Frame(gtx.Ops)
case system.DestroyEvent:
abortFn()
return e.Err
}
case res := <-g.proc.wrk:
if res.done {
g.proc.isDone = true
break
}
if resizeXY {
continue
}
g.proc.img = res.img
g.proc.seams = res.carver.Seams
if mask, ok := g.huds[1]; ok {
if mask.visible.Value {
srcBitmap := imop.NewBitmap(res.img.Bounds())
dstBitmap := imop.NewBitmap(res.img.Bounds())
uniform := image.NewNRGBA(res.img.Bounds())
col := color.RGBA{R: 0x2f, G: 0xf3, B: 0xe0, A: 0xff}
draw.Draw(uniform, uniform.Bounds(), &image.Uniform{col}, image.Point{}, draw.Src)
g.cop.Set(imop.DstIn)
g.cop.Draw(srcBitmap, res.debug, uniform, nil)
g.bop.Set(imop.ColorMode)
g.cop.Set(imop.DstOver)
g.cop.Draw(dstBitmap, res.img, srcBitmap.Img, g.bop)
g.proc.img = dstBitmap.Img
}
}
if g.cp.vRes {
g.proc.img = res.carver.RotateImage270(g.proc.img.(*image.NRGBA))
}
w.Invalidate()
}
}
}
type (
C = layout.Context
D = layout.Dimensions
)
// draw draws the resized image in the GUI window (obtained from a channel)
// and in case the debug mode is activated it prints out the seams.
func (g *Gui) draw(gtx layout.Context, bgCol color.NRGBA) {
g.ctx = gtx
op.InvalidateOp{}.Add(gtx.Ops)
c := g.setColor(g.cfg.color.background)
paint.Fill(g.ctx.Ops, c)
if g.proc.img != nil {
src := paint.NewImageOp(g.proc.img)
src.Add(g.ctx.Ops)
layout.Stack{}.Layout(g.ctx,
layout.Stacked(func(gtx C) D {
paint.FillShape(gtx.Ops, c,
clip.Rect{Max: g.ctx.Constraints.Max}.Op(),
)
return layout.UniformInset(unit.Dp(0)).Layout(gtx,
func(gtx C) D {
widget.Image{
Src: src,
Scale: 1 / float32(unit.Dp(1)),
Fit: widget.Contain,
}.Layout(gtx)
if seam, ok := g.huds[0]; ok {
if seam.visible.Value {
tr := f32.Affine2D{}
screen := layout.FPt(g.ctx.Constraints.Max)
width, height := float32(g.proc.img.Bounds().Dx()), float32(g.proc.img.Bounds().Dy())
sw, sh := float32(screen.X), float32(screen.Y)
if sw > width {
ratio := sw / width
tr = tr.Scale(f32.Pt(sw/2, sh/2), f32.Pt(1, ratio))
} else if sh > height {
ratio := sh / height
tr = tr.Scale(f32.Pt(sw/2, sh/2), f32.Pt(ratio, 1))
}
if g.cp.vRes {
angle := float32(270 * math.Pi / 180)
half := float32(math.Round(float64(sh*0.5-height*0.5) * 0.5))
ox := math.Abs(float64(sw - (sw - (sw/2 - sh/2))))
oy := math.Abs(float64(sh - (sh - (sw/2 - height/2 + half))))
tr = tr.Rotate(f32.Pt(sw/2, sh/2), -angle)
if screen.X > screen.Y {
tr = tr.Offset(f32.Pt(float32(ox), float32(oy)))
} else {
tr = tr.Offset(f32.Pt(float32(-ox), float32(-oy)))
}
}
op.Affine(tr).Add(gtx.Ops)
for _, s := range g.proc.seams {
dpx := unit.Dp(s.X)
dpy := unit.Dp(s.Y)
// Convert the image coordinates from pixel values to DP units.
dpiy := unit.Dp(float32(g.cfg.window.w) / float32(300))
dpix := unit.Dp(float32(g.cfg.window.h) / float32(300))
g.DrawSeam(g.cp.ShapeType, float32(dpx*dpix), float32(dpy*dpiy), 2.0)
}
}
}
return layout.Dimensions{Size: gtx.Constraints.Max}
})
}),
)
}
if g.cp.Debug {
layout.Stack{}.Layout(g.ctx,
layout.Stacked(func(gtx C) D {
hudHeight := 40
r := image.Rectangle{
Max: image.Point{
X: gtx.Constraints.Max.X,
Y: hudHeight,
},
}
defer op.Offset(image.Pt(0, gtx.Constraints.Max.Y-hudHeight)).Push(gtx.Ops).Pop()
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx C) D {
paint.FillShape(gtx.Ops, color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xcc}, clip.Rect(r).Op())
return layout.Dimensions{Size: r.Max}
}),
layout.Stacked(func(gtx C) D {
border := image.Rectangle{
Max: image.Point{
X: gtx.Constraints.Max.X,
Y: gtx.Dp(unit.Dp(0.5)),
},
}
paint.FillShape(gtx.Ops, color.NRGBA{R: 0x3B, G: 0x41, B: 0x3C, A: 0xaa}, clip.Rect(border).Op())
return layout.Dimensions{Size: r.Max}
}),
layout.Stacked(func(gtx C) D {
return g.view.huds.Layout(gtx, len(g.huds),
func(gtx layout.Context, index int) D {
if hud, ok := g.huds[index]; ok {
checkbox := material.CheckBox(g.th, &hud.visible, fmt.Sprintf("%v", hud.title))
checkbox.Size = 20
return checkbox.Layout(gtx)
}
return D{}
})
}),
)
}),
)
}
// Disable the preview mode and warn the user in case the image is resized both horizontally and vertically.
if resizeXY {
var msg string
if !g.proc.isDone {
msg = "Preview is not available while the image is resized both horizontally and vertically!"
} else {
msg = "Done, you may close this window!"
bgCol = color.NRGBA{R: 45, G: 45, B: 42, A: 0xff}
}
g.displayMessage(g.ctx, bgCol, msg)
}
}
// displayMessage show a static message when the image is resized both horizontally and vertically.
func (g *Gui) displayMessage(ctx layout.Context, bgCol color.NRGBA, msg string) {
g.th.Palette.Fg = color.NRGBA{R: 251, G: 254, B: 249, A: 0xff}
paint.ColorOp{Color: bgCol}.Add(ctx.Ops)
rect := image.Rectangle{
Max: ctx.Constraints.Max,
}
defer clip.Rect(rect).Push(ctx.Ops).Pop()
paint.PaintOp{}.Add(ctx.Ops)
layout.Stack{}.Layout(ctx,
layout.Stacked(func(gtx C) D {
return layout.UniformInset(unit.Dp(4)).Layout(ctx, func(gtx C) D {
if !g.proc.isDone {
gtx.Constraints.Min.Y = 0
tr := f32.Affine2D{}
dr := image.Rectangle{Max: gtx.Constraints.Min}
tr = tr.Rotate(f32.Pt(float32(ctx.Constraints.Max.X/2), float32(ctx.Constraints.Max.Y/2)), 0.005*-g.cfg.angle)
op.Affine(tr).Add(gtx.Ops)
since := time.Since(g.cfg.timeStamp)
if since.Seconds() > 5 {
g.cfg.timeStamp = time.Now()
g.cfg.color.randR = uint8(random(1, 2))
g.cfg.color.randG = uint8(random(1, 2))
g.cfg.color.randB = uint8(random(1, 2))
}
paint.LinearGradientOp{
Stop1: layout.FPt(dr.Min.Div(2)),
Stop2: layout.FPt(dr.Max.Mul(2)),
Color1: color.NRGBA{R: 41, G: bgCol.G * g.cfg.color.randG, B: bgCol.B * g.cfg.color.randB, A: 0xFF},
Color2: color.NRGBA{R: bgCol.R * g.cfg.color.randR, G: 29, B: 54, A: 0xFF},
}.Add(gtx.Ops)
paint.PaintOp{}.Add(gtx.Ops)
if g.cfg.chrot {
g.cfg.angle--
} else {
g.cfg.angle++
}
if g.cfg.angle == -90 || g.cfg.angle == 90 {
g.cfg.chrot = !g.cfg.chrot
}
}
return layout.Dimensions{
Size: gtx.Constraints.Max,
}
})
}),
layout.Stacked(func(gtx C) D {
return layout.UniformInset(unit.Dp(4)).Layout(ctx, func(gtx C) D {
return layout.Center.Layout(ctx, func(gtx C) D {
m := material.Label(g.th, unit.Sp(40), msg)
m.Alignment = text.Middle
return m.Layout(gtx)
})
})
}),
layout.Stacked(func(gtx C) D {
info := "(You will be notified once the process is finished.)"
if g.proc.isDone {
return layout.Dimensions{}
}
return layout.Inset{Top: 70}.Layout(ctx, func(gtx C) D {
return layout.Center.Layout(ctx, func(gtx C) D {
return material.Label(g.th, unit.Sp(13), info).Layout(gtx)
})
})
}),
)
}
// random generates a random number between two numbers.
func random(min, max float32) float32 {
return rand.Float32()*(max-min) + min
}