internal.PopIn is gone. Breadcrumb, NavigationMenu, Pagination, Menubar panels, CommandPalette, Select (and the other selectPanel users), datetime popups, ColorPicker and the StatusBar overflow now style their panel with overlayStyle(c, panel, overlayEnter(c, panel)): one shadow (0 6 18, 16% black, scaled by entry) and one 160 ms entry fade. Snapshots: color_picker and command_palette (light and dark) change only in the panel shadow. ColorPicker loses its 0 4 12 18% shadow for the softer shared one; CommandPalette loses its heavy 0 12 32 30% shadow, and its backdrop now fades by alpha with the panel instead of through the backdrop's opacity. Fade and reduced-motion tests pass unchanged.
400 lines
13 KiB
Go
400 lines
13 KiB
Go
package mujica
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/icons"
|
|
"mujicaui/internal"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// ColorPickerOptions configure a ColorPicker.
|
|
type ColorPickerOptions struct {
|
|
// Presets are offered as swatches; nil uses the built-in court palette.
|
|
Presets []ui.Color
|
|
// Label names the trigger; empty uses the built-in "Color".
|
|
Label string
|
|
Disabled bool
|
|
}
|
|
|
|
// ColorPickerParts are the parts of a ColorPicker.
|
|
type ColorPickerParts struct {
|
|
Trigger *ui.Element
|
|
changed bool
|
|
}
|
|
|
|
// Changed reports whether the user picked a new color this frame.
|
|
func (p ColorPickerParts) Changed() bool { return p.changed }
|
|
|
|
// colorPickerPresets are the default swatches: court tones and neutrals.
|
|
var colorPickerPresets = []ui.Color{
|
|
ui.Hex("#7D2034"), ui.Hex("#A12635"), ui.Hex("#AD9164"), ui.Hex("#805714"),
|
|
ui.Hex("#326247"), ui.Hex("#355E80"), ui.Hex("#4B3A5A"), ui.Hex("#211C1D"),
|
|
ui.Hex("#6A5B55"), ui.Hex("#D2C3AF"), ui.Hex("#F2EBDD"), ui.Hex("#FFFFFF"),
|
|
}
|
|
|
|
// colorPickerHSVA keeps hue for grays, which RGB loses.
|
|
type colorPickerHSVA struct{ h, s, v, a float64 }
|
|
|
|
type colorPickerState struct {
|
|
open, wasOpen bool
|
|
hsv colorPickerHSVA
|
|
last ui.Color
|
|
hex, r, g, b string
|
|
alpha string
|
|
bad string
|
|
}
|
|
|
|
// ColorPicker is a swatch button opening a panel that picks *color.
|
|
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
label := opts.Label
|
|
if label == "" {
|
|
label = msg(c, "color_picker.label")
|
|
}
|
|
presets := opts.Presets
|
|
if presets == nil {
|
|
presets = colorPickerPresets
|
|
}
|
|
parts := ColorPickerParts{}
|
|
trigger := ui.ButtonBase(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*1.5).Gap(d.Unit() * 2).
|
|
Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled).Label(label)
|
|
parts.Trigger = trigger
|
|
internal.Ring(trigger, k.Focus, theme.ControlRadius)
|
|
st := ui.Local(trigger, "picker", func() colorPickerState {
|
|
s := colorPickerState{last: *color, hsv: colorPickerToHSVA(*color)}
|
|
s.sync(*color)
|
|
return s
|
|
})
|
|
if *color != st.last {
|
|
st.hsv, st.last = colorPickerToHSVA(*color), *color
|
|
st.sync(*color)
|
|
}
|
|
set := func(x colorPickerHSVA, fields bool) {
|
|
x.s, x.v, x.a = colorPickerClamp(x.s), colorPickerClamp(x.v), colorPickerClamp(x.a)
|
|
st.hsv = x
|
|
next := x.color()
|
|
if next != *color {
|
|
slog.Debug("mujica: color picker changed", "color", colorPickerHex(next))
|
|
*color = next
|
|
parts.changed = true
|
|
}
|
|
st.last = *color
|
|
if fields {
|
|
st.sync(*color)
|
|
}
|
|
}
|
|
if trigger.IsDisabled() {
|
|
st.open = false
|
|
} else if trigger.Clicked() {
|
|
st.open = !st.open
|
|
}
|
|
border := k.ControlBorder
|
|
if trigger.Hovered() {
|
|
border = k.ControlBorder.Mix(k.Text, 0.25)
|
|
trigger.Background(k.SurfaceHover)
|
|
}
|
|
trigger.Border(theme.BorderWidth, border).Description(colorPickerHex(*color))
|
|
now := *color
|
|
trigger.Children(func() {
|
|
ui.Box(c).Size(d.Unit()*6, d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Clip().Shrink(0).
|
|
Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) {
|
|
colorPickerCheckers(p, r, 4)
|
|
p.Fill(r, now, 0)
|
|
})
|
|
ui.Text(c, strings.ToUpper(colorPickerHex(now))).Font(theme.MonoFont).SingleLine()
|
|
ui.Icon(c, icons.Must("chevron-down")).Size(16, 16).TextColor(k.TextMuted)
|
|
})
|
|
opened := st.open && !st.wasOpen
|
|
ui.PopoverBase(c, trigger, &st.open, func(panel *ui.Element) {
|
|
overlayStyle(c, panel, overlayEnter(c, panel))
|
|
panel.Margin(d.Unit(), 0, 0, 0).Padding(d.Unit() * 3).Gap(d.Unit() * 3).Width(260)
|
|
colorPickerSquare(c, st, set, opened)
|
|
x := st.hsv
|
|
h := x.h
|
|
if colorPickerSlider(c, &h, 360, msg(c, "color_picker.hue"), func(p *ui.Painter, r ui.Rect) {
|
|
hues := []ui.Color{ui.RGB(255, 0, 0), ui.RGB(255, 255, 0), ui.RGB(0, 255, 0), ui.RGB(0, 255, 255), ui.RGB(0, 0, 255), ui.RGB(255, 0, 255), ui.RGB(255, 0, 0)}
|
|
w := r.W / 6
|
|
for i := range 6 {
|
|
p.FillGradient(ui.Rect{X: r.X + w*float32(i), Y: r.Y, W: w + 0.5, H: r.H}, ui.LinearGradient{From: hues[i], To: hues[i+1], Angle: 90}, 0)
|
|
}
|
|
}).Changed() {
|
|
set(colorPickerHSVA{h, x.s, x.v, x.a}, true)
|
|
}
|
|
a := x.a
|
|
opaque := colorPickerHSVA{x.h, x.s, x.v, 1}.color()
|
|
if colorPickerSlider(c, &a, 1, msg(c, "color_picker.alpha"), func(p *ui.Painter, r ui.Rect) {
|
|
colorPickerCheckers(p, r, 4)
|
|
p.FillGradient(r, ui.LinearGradient{From: opaque.Alpha(0), To: opaque, Angle: 90}, 0)
|
|
}).Changed() {
|
|
set(colorPickerHSVA{x.h, x.s, x.v, a}, true)
|
|
}
|
|
colorPickerFields(c, st, color, set)
|
|
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
|
|
for _, sw := range presets {
|
|
hex := strings.ToUpper(colorPickerHex(sw))
|
|
b := ui.ButtonBase(c).Height(theme.MinHitSize).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
|
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
|
chosen := *color == sw
|
|
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
|
|
colorPickerCheckers(p, r, 4)
|
|
p.Fill(r, sw, 0)
|
|
})
|
|
if chosen {
|
|
mark := ui.RGB(255, 255, 255)
|
|
if colorPickerLuma(sw) > 0.55 {
|
|
mark = ui.RGB(0, 0, 0)
|
|
}
|
|
b.Children(func() { ui.Icon(c, icons.Must("check")).Size(14, 14).TextColor(mark) })
|
|
}
|
|
if b.Clicked() {
|
|
set(colorPickerToHSVA(sw), true)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
if st.wasOpen && !st.open && !trigger.IsDisabled() {
|
|
trigger.Focus()
|
|
}
|
|
st.wasOpen = st.open
|
|
return parts
|
|
}
|
|
|
|
// colorPickerSquare picks saturation across and brightness up.
|
|
func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPickerHSVA, bool), opened bool) {
|
|
k := Tokens(c)
|
|
x := st.hsv
|
|
sq := ui.Box(c).Height(150).Radius(theme.ControlRadius).Focusable().Role(ui.RoleSlider).Cursor(ui.CursorCrosshair).
|
|
Label(msg(c, "color_picker.square"))
|
|
sq.FocusRing(false)
|
|
if opened {
|
|
sq.Focus()
|
|
}
|
|
if _, _, ok := sq.Dragged(); ok {
|
|
px, py, _ := sq.PointerPosition()
|
|
if b := sq.Bounds(); b.W > 0 && b.H > 0 {
|
|
set(colorPickerHSVA{x.h, float64(px / b.W), 1 - float64(py/b.H), x.a}, true)
|
|
}
|
|
}
|
|
switch {
|
|
case sq.Shortcut(0, ui.KeyRight):
|
|
set(colorPickerHSVA{x.h, x.s + 0.01, x.v, x.a}, true)
|
|
case sq.Shortcut(0, ui.KeyLeft):
|
|
set(colorPickerHSVA{x.h, x.s - 0.01, x.v, x.a}, true)
|
|
case sq.Shortcut(0, ui.KeyUp):
|
|
set(colorPickerHSVA{x.h, x.s, x.v + 0.01, x.a}, true)
|
|
case sq.Shortcut(0, ui.KeyDown):
|
|
set(colorPickerHSVA{x.h, x.s, x.v - 0.01, x.a}, true)
|
|
}
|
|
x = st.hsv
|
|
sq.Description(fmt.Sprintf(msg(c, "color_picker.sv"), x.s*100, x.v*100))
|
|
hue := colorPickerHSVA{x.h, 1, 1, 1}.color()
|
|
sq.Draw(func(p *ui.Painter, r ui.Rect) {
|
|
p.FillGradient(r, ui.LinearGradient{From: ui.RGB(255, 255, 255), To: hue, Angle: 90}, theme.ControlRadius)
|
|
p.FillGradient(r, ui.LinearGradient{From: ui.RGBA(0, 0, 0, 0), To: ui.RGB(0, 0, 0), Angle: 180}, theme.ControlRadius)
|
|
cx, cy := internal.Snap(p, r.X+r.W*float32(x.s)), internal.Snap(p, r.Y+r.H*float32(1-x.v))
|
|
p.FillPath(internal.Diamond(cx, cy, 14), ui.RGB(0, 0, 0).Alpha(0.5))
|
|
p.FillPath(internal.Diamond(cx, cy, 11), ui.RGB(255, 255, 255))
|
|
p.FillPath(internal.Diamond(cx, cy, 6), x.color().Alpha(1))
|
|
if sq.FocusVisible() {
|
|
internal.FocusRing(p, r, theme.ControlRadius, k.Focus)
|
|
}
|
|
})
|
|
}
|
|
|
|
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
|
|
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
|
|
k := Tokens(c)
|
|
const knob, look = 10, 18
|
|
s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name)
|
|
frac := float32(*v / hi)
|
|
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
|
// The hit area is taller than the drawn slider, which sits in its middle.
|
|
r = ui.Rect{X: r.X, Y: r.Y + (r.H-look)/2, W: r.W, H: look}
|
|
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
|
|
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
|
|
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
|
|
x := r.X + knob/2 + (r.W-knob)*frac
|
|
thumb := ui.Rect{X: x - knob/2, Y: r.Y, W: knob, H: r.H}
|
|
p.Fill(thumb, k.Surface, theme.ControlRadius)
|
|
p.Stroke(thumb, k.Text, theme.ControlRadius, 1.5)
|
|
if s.FocusVisible() {
|
|
internal.FocusRing(p, thumb, theme.ControlRadius, k.Focus)
|
|
}
|
|
})
|
|
return s
|
|
}
|
|
|
|
// sync shows col in every field.
|
|
func (st *colorPickerState) sync(col ui.Color) {
|
|
st.hex = strings.TrimPrefix(strings.ToUpper(colorPickerHex(col)), "#")
|
|
st.r, st.g, st.b = strconv.Itoa(int(col.R)), strconv.Itoa(int(col.G)), strconv.Itoa(int(col.B))
|
|
st.alpha = strconv.Itoa(int(math.Round(float64(col.A) / 255 * 100)))
|
|
st.bad = ""
|
|
}
|
|
|
|
// colorPickerFields edits the color as HEX, RGB and opacity text.
|
|
func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set func(colorPickerHSVA, bool)) {
|
|
k := Tokens(c)
|
|
d := Density(c)
|
|
apply := func(field string, text *string, col ui.Color, ok bool) {
|
|
if !ok {
|
|
st.bad = field
|
|
return
|
|
}
|
|
kept := *text
|
|
set(colorPickerToHSVA(col), true)
|
|
*text = kept
|
|
}
|
|
editing := false
|
|
channel := func(s string) (uint8, bool) {
|
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
|
return uint8(n), err == nil && n >= 0 && n <= 255
|
|
}
|
|
ui.Column(c).Gap(d.Unit() * 2).Children(func() {
|
|
hex := InputGroup(c, &st.hex, InputGroupOptions{Prefix: "#", Label: "HEX", Invalid: st.bad == "hex"})
|
|
editing = hex.Input.Focused()
|
|
if hex.Changed() {
|
|
col, err := colorPickerParseHex(st.hex)
|
|
apply("hex", &st.hex, col, err == nil)
|
|
}
|
|
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
|
|
for _, f := range []struct {
|
|
name, label string
|
|
text *string
|
|
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
|
|
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
|
|
ui.Column(c).Gap(d.Unit()).Children(func() {
|
|
ui.Text(c, f.name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
|
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
|
|
editing = editing || in.Input.Focused()
|
|
if !in.Changed() {
|
|
return
|
|
}
|
|
col := *color
|
|
v, ok := channel(*f.text)
|
|
switch f.name {
|
|
case "R":
|
|
col.R = v
|
|
case "G":
|
|
col.G = v
|
|
case "B":
|
|
col.B = v
|
|
default:
|
|
ok = ok && v <= 100
|
|
col.A = uint8(math.Round(float64(v) / 100 * 255))
|
|
}
|
|
apply(f.name, f.text, col, ok)
|
|
})
|
|
}
|
|
})
|
|
if !editing {
|
|
st.sync(*color)
|
|
}
|
|
if st.bad != "" {
|
|
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
|
|
}
|
|
})
|
|
}
|
|
|
|
// colorPickerHex returns col as #rrggbb, or #rrggbbaa when translucent.
|
|
func colorPickerHex(col ui.Color) string {
|
|
if col.A == 255 {
|
|
return fmt.Sprintf("#%02x%02x%02x", col.R, col.G, col.B)
|
|
}
|
|
return fmt.Sprintf("#%02x%02x%02x%02x", col.R, col.G, col.B, col.A)
|
|
}
|
|
|
|
// colorPickerParseHex parses rgb, rrggbb or rrggbbaa, with or without "#".
|
|
func colorPickerParseHex(s string) (ui.Color, error) {
|
|
h := strings.TrimPrefix(strings.TrimSpace(s), "#")
|
|
if len(h) == 3 {
|
|
h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]})
|
|
}
|
|
if len(h) == 6 {
|
|
h += "ff"
|
|
}
|
|
if len(h) != 8 {
|
|
return ui.Color{}, fmt.Errorf("mujica: color %q is not hex", s)
|
|
}
|
|
v, err := strconv.ParseUint(h, 16, 32)
|
|
if err != nil {
|
|
return ui.Color{}, fmt.Errorf("mujica: color %q: %w", s, err)
|
|
}
|
|
return ui.Color{R: uint8(v >> 24), G: uint8(v >> 16), B: uint8(v >> 8), A: uint8(v)}, nil
|
|
}
|
|
|
|
func colorPickerToHSVA(col ui.Color) colorPickerHSVA {
|
|
r, g, b := float64(col.R)/255, float64(col.G)/255, float64(col.B)/255
|
|
hi, lo := math.Max(r, math.Max(g, b)), math.Min(r, math.Min(g, b))
|
|
d := hi - lo
|
|
var h float64
|
|
switch {
|
|
case d == 0:
|
|
case hi == r:
|
|
h = 60 * math.Mod((g-b)/d, 6)
|
|
case hi == g:
|
|
h = 60 * ((b-r)/d + 2)
|
|
default:
|
|
h = 60 * ((r-g)/d + 4)
|
|
}
|
|
if h < 0 {
|
|
h += 360
|
|
}
|
|
s := 0.0
|
|
if hi > 0 {
|
|
s = d / hi
|
|
}
|
|
return colorPickerHSVA{h, s, hi, float64(col.A) / 255}
|
|
}
|
|
|
|
func (x colorPickerHSVA) color() ui.Color {
|
|
ch := x.v * x.s
|
|
h := math.Mod(x.h, 360) / 60
|
|
y := ch * (1 - math.Abs(math.Mod(h, 2)-1))
|
|
var r, g, b float64
|
|
switch {
|
|
case h < 1:
|
|
r, g = ch, y
|
|
case h < 2:
|
|
r, g = y, ch
|
|
case h < 3:
|
|
g, b = ch, y
|
|
case h < 4:
|
|
g, b = y, ch
|
|
case h < 5:
|
|
r, b = y, ch
|
|
default:
|
|
r, b = ch, y
|
|
}
|
|
m := x.v - ch
|
|
byte8 := func(v float64) uint8 { return uint8(math.Round(colorPickerClamp(v) * 255)) }
|
|
return ui.Color{R: byte8(r + m), G: byte8(g + m), B: byte8(b + m), A: byte8(x.a)}
|
|
}
|
|
|
|
func colorPickerClamp(v float64) float64 { return math.Max(0, math.Min(1, v)) }
|
|
|
|
// colorPickerLuma is the relative brightness of col, 0 to 1.
|
|
func colorPickerLuma(col ui.Color) float64 {
|
|
return (0.2126*float64(col.R) + 0.7152*float64(col.G) + 0.0722*float64(col.B)) / 255
|
|
}
|
|
|
|
// colorPickerCheckers paints the grid that shows transparency.
|
|
func colorPickerCheckers(p *ui.Painter, r ui.Rect, size float32) {
|
|
p.Fill(r, ui.RGB(255, 255, 255), 0)
|
|
for y, row := r.Y, 0; y < r.Y+r.H; y, row = y+size, row+1 {
|
|
for x, col := r.X, 0; x < r.X+r.W; x, col = x+size, col+1 {
|
|
if (row+col)%2 == 0 {
|
|
p.Fill(ui.Rect{X: x, Y: y, W: min(size, r.X+r.W-x), H: min(size, r.Y+r.H-y)}, ui.RGB(204, 204, 204), 0)
|
|
}
|
|
}
|
|
}
|
|
}
|