193 lines
6.1 KiB
Go
193 lines
6.1 KiB
Go
package mujica
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/icons"
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// ViewerImage is one picture of an ImageViewer.
|
|
type ViewerImage struct {
|
|
// Source is a *ui.Bitmap or *ui.SVG; required.
|
|
Source ui.ImageSource
|
|
// Alt describes the picture for assistive technology and the caption.
|
|
Alt string
|
|
}
|
|
|
|
// ImageViewerView is a built ImageViewer; Element is nil while closed.
|
|
type ImageViewerView struct {
|
|
*ui.Element
|
|
changed bool
|
|
}
|
|
|
|
// Changed reports that another image was chosen.
|
|
func (v ImageViewerView) Changed() bool { return v.changed }
|
|
|
|
// Zoom limits, as multiples of the fitted size.
|
|
const (
|
|
viewerMinZoom = 0.25
|
|
viewerMaxZoom = 8
|
|
viewerZoomStep = 1.25
|
|
viewerPanStep = 48
|
|
)
|
|
|
|
type viewerState struct {
|
|
image int
|
|
zoom, x, y float32
|
|
}
|
|
|
|
// ImageViewer shows images[*index] over the window while *open, with zoom, pan and switching.
|
|
func ImageViewer(c *ui.Context, open *bool, index *int, images []ViewerImage) ImageViewerView {
|
|
var v ImageViewerView
|
|
if !*open {
|
|
return v
|
|
}
|
|
if len(images) == 0 || *index < 0 || *index >= len(images) {
|
|
panic("mujica: ImageViewer index out of range")
|
|
}
|
|
k := Tokens(c)
|
|
u := Density(c).Unit()
|
|
w, h := c.Size()
|
|
v.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
|
|
back.Background(ui.RGBA(0, 0, 0, 0.55))
|
|
panel.Background(k.Surface).Border(theme.BorderWidth, k.Border).Radius(theme.PopupRadius).
|
|
Size(w-u*12, h-u*12).Padding(u * 3).Gap(u * 3).Label(msg(c, "image_viewer.title"))
|
|
st := ui.Local(panel, "view", func() viewerState { return viewerState{image: -1} })
|
|
if st.image != *index {
|
|
*st = viewerState{image: *index, zoom: 1}
|
|
}
|
|
goTo := func(i int) {
|
|
if i < 0 || i >= len(images) || i == *index {
|
|
return
|
|
}
|
|
*index, v.changed = i, true
|
|
*st = viewerState{image: i, zoom: 1}
|
|
c.Announce(fmt.Sprintf(msg(c, "image_viewer.position"), i+1, len(images)))
|
|
slog.Debug("mujica: image_viewer image", "index", i)
|
|
}
|
|
zoom := func(f float32) {
|
|
st.zoom = float32(math.Max(viewerMinZoom, math.Min(viewerMaxZoom, float64(st.zoom*f))))
|
|
}
|
|
viewerKeys(panel, st, zoom, goTo, *index)
|
|
img := images[*index]
|
|
tool := func(icon, label string, disabled bool) bool {
|
|
return Button(c, "", ButtonOptions{Variant: Ghost, Icon: icons.Must(icon), Label: msg(c, label), Disabled: disabled}).Clicked()
|
|
}
|
|
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
|
ui.Text(c, fmt.Sprintf(msg(c, "image_viewer.position"), *index+1, len(images))).TextColor(k.TextMuted).Grow(1)
|
|
if tool("zoom-out", "image_viewer.zoomOut", st.zoom <= viewerMinZoom) {
|
|
zoom(1 / viewerZoomStep)
|
|
}
|
|
ui.Text(c, fmt.Sprintf("%d%%", int(math.Round(float64(st.zoom*100))))).Width(48).TextAlign(ui.Center).SingleLine()
|
|
if tool("zoom-in", "image_viewer.zoomIn", st.zoom >= viewerMaxZoom) {
|
|
zoom(viewerZoomStep)
|
|
}
|
|
if tool("maximize", "image_viewer.fit", st.zoom == 1 && st.x == 0 && st.y == 0) {
|
|
*st = viewerState{image: *index, zoom: 1}
|
|
}
|
|
if tool("x", "image_viewer.close", false) {
|
|
*open = false
|
|
}
|
|
})
|
|
ui.Row(c).Grow(1).MinHeight(0).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
|
if tool("chevron-left", "image_viewer.prev", *index == 0) {
|
|
goTo(*index - 1)
|
|
}
|
|
stage := ui.Box(c).Grow(1).FillHeight().MinWidth(0).Focusable().AutoFocus().Clip().
|
|
Radius(theme.ControlRadius).Background(k.Background).Role(ui.RoleImage).Label(img.Alt).Cursor(ui.CursorGrab)
|
|
internal.Ring(stage, k.Focus, theme.ControlRadius)
|
|
if dx, dy, ok := stage.Dragged(); ok {
|
|
st.x, st.y = st.x+dx, st.y+dy
|
|
}
|
|
stage.HandleInput(func(ev ui.InputEvent) bool {
|
|
if ev.Kind != ui.InputScroll || ev.DY == 0 {
|
|
return false
|
|
}
|
|
if ev.DY < 0 {
|
|
zoom(viewerZoomStep)
|
|
} else {
|
|
zoom(1 / viewerZoomStep)
|
|
}
|
|
return true
|
|
})
|
|
iw, ih := viewerSize(img.Source)
|
|
place := func(r ui.Rect) (dw, dh, mx, my float32) {
|
|
fit := float32(math.Min(float64(r.W/iw), float64(r.H/ih)))
|
|
dw, dh = iw*fit*st.zoom, ih*fit*st.zoom
|
|
// The picture cannot be dragged past its edges.
|
|
return dw, dh, max(0, (dw-r.W)/2), max(0, (dh-r.H)/2)
|
|
}
|
|
if b := stage.Bounds(); b.W > 0 {
|
|
_, _, mx, my := place(b)
|
|
st.x, st.y = min(mx, max(-mx, st.x)), min(my, max(-my, st.y))
|
|
}
|
|
x, y := st.x, st.y
|
|
stage.Draw(func(p *ui.Painter, r ui.Rect) {
|
|
dw, dh, mx, my := place(r)
|
|
ox, oy := min(mx, max(-mx, x)), min(my, max(-my, y))
|
|
p.Clip(r, theme.ControlRadius, func() {
|
|
p.Image(img.Source, ui.Rect{X: r.X + (r.W-dw)/2 + ox, Y: r.Y + (r.H-dh)/2 + oy, W: dw, H: dh}, ui.FillBox)
|
|
})
|
|
})
|
|
if tool("chevron-right", "image_viewer.next", *index == len(images)-1) {
|
|
goTo(*index + 1)
|
|
}
|
|
})
|
|
if img.Alt != "" {
|
|
ui.Text(c, img.Alt).TextColor(k.TextMuted).TextAlign(ui.Center).FillWidth()
|
|
}
|
|
})
|
|
if !*open {
|
|
slog.Debug("mujica: image_viewer close")
|
|
}
|
|
return v
|
|
}
|
|
|
|
// viewerKeys, anywhere in the panel, zooms with + - 0, switches with Left and Right, and pans with Shift+arrows.
|
|
func viewerKeys(panel *ui.Element, st *viewerState, zoom func(float32), goTo func(int), at int) {
|
|
switch {
|
|
case panel.Shortcut(0, ui.KeyEqual), panel.Shortcut(ui.Shift, ui.KeyEqual):
|
|
zoom(viewerZoomStep)
|
|
case panel.Shortcut(0, ui.KeyMinus):
|
|
zoom(1 / viewerZoomStep)
|
|
case panel.Shortcut(0, ui.Key0):
|
|
*st = viewerState{image: st.image, zoom: 1}
|
|
case panel.Shortcut(0, ui.KeyLeft):
|
|
goTo(at - 1)
|
|
case panel.Shortcut(0, ui.KeyRight):
|
|
goTo(at + 1)
|
|
case panel.Shortcut(ui.Shift, ui.KeyLeft):
|
|
st.x += viewerPanStep
|
|
case panel.Shortcut(ui.Shift, ui.KeyRight):
|
|
st.x -= viewerPanStep
|
|
case panel.Shortcut(ui.Shift, ui.KeyUp):
|
|
st.y += viewerPanStep
|
|
case panel.Shortcut(ui.Shift, ui.KeyDown):
|
|
st.y -= viewerPanStep
|
|
}
|
|
}
|
|
|
|
// viewerSize returns a picture's own size in DIPs.
|
|
func viewerSize(src ui.ImageSource) (float32, float32) {
|
|
var w, h float32
|
|
switch s := src.(type) {
|
|
case *ui.Bitmap:
|
|
iw, ih := s.Size()
|
|
w, h = float32(iw), float32(ih)
|
|
case *ui.SVG:
|
|
w, h = s.Size()
|
|
default:
|
|
panic(fmt.Sprintf("mujica: ImageViewer source %T is not a *ui.Bitmap or *ui.SVG", src))
|
|
}
|
|
if w <= 0 || h <= 0 {
|
|
panic("mujica: ImageViewer source without a size")
|
|
}
|
|
return w, h
|
|
}
|