-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbutton.go
More file actions
348 lines (317 loc) · 12.9 KB
/
Copy pathbutton.go
File metadata and controls
348 lines (317 loc) · 12.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
// Copyright (c) 2026 the wasmdesk/toolkit authors. All rights reserved.
// Use of this source code is governed by a BSD-3-Clause license that can be
// found in the LICENSE file at the root of this repository.
package toolkit
import (
"github.com/go-widgets/mvvm"
"github.com/go-widgets/painter"
)
// Button is a clickable rectangle with a centred label. Paints a
// 1-pixel border in Theme.Border on a Theme.Surface body; hovered /
// pressed states cycle through SurfaceAlt + Accent so the user sees
// click feedback before the callback fires.
//
// Wire a handler via OnClick; the button calls it from OnEvent when
// it receives an EventClick. Callers re-paint via Draw after any
// state mutation (the toolkit doesn't drive its own frame loop --
// the wasmbox compositor's tick is the redraw trigger).
type Button struct {
Base
focusState
// label is the button caption, reactive via the Label() accessor.
label *mvvm.Observable[string]
OnClick func()
Style ButtonStyle // resting appearance; default is ButtonDefault
// Icon, when set, lets the host paint a real vector glyph in the button's
// face instead of the text Label — the seam other widgets use for
// host-supplied icons (mirrors Banner.Icon / SearchEntry.Icon). Draw invokes
// Icon with the button's full bounds and the current face ink (which already
// carries the pressed / disabled tint), so the glyph tracks every button
// state; the callback is responsible for centring + sizing itself within the
// rect. When nil the button falls back to drawing Label, so existing callers
// are unaffected and headless renders still show text.
Icon func(p painter.Painter, r Rect, ink RGBA)
// selected is a sticky, app-managed "active" state (a pill in a selector, the
// current tab/provider): when true the button fills with Accent regardless of
// Style. The app drives it from its own model via the Selected() Observable;
// the button never flips it itself.
selected *mvvm.Observable[bool]
// leadingIcon and shortcut are OPTIONAL, reactive adornments drawn ALONGSIDE the
// caption (unlike the Icon field above, which replaces it): a small vector glyph
// before the label, and a right-aligned keyboard-shortcut hint (e.g. "⌘F") in a
// muted tone. Both are lazily-created Observables via LeadingIcon()/Shortcut();
// a button that never sets them draws exactly as before.
leadingIcon *mvvm.Observable[IconFunc]
shortcut *mvvm.Observable[string]
// PressFeedback shows the pressed face on EventClick (until EventMouseUp).
// NewButton enables it; set it false to opt a button out (e.g. one whose
// action already navigates away so the flash would just flicker).
PressFeedback bool
// Flat suppresses the button's own rounded border + fill rounding, painting a
// square-cornered face only — so it can sit inside a container that owns the
// shared chrome (see ButtonGroup, which sets it on its members). The zero
// value (false) keeps the standalone rounded look, so existing callers are
// unaffected.
Flat bool
hovered bool
pressed bool
}
// ButtonStyle selects a button's resting fill, giving a layout visual hierarchy
// (macOS "prominent"/default/secondary buttons). Hover + press still override
// the fill on top of the style.
type ButtonStyle int
const (
// ButtonDefault is a Surface-faced button (the plain look).
ButtonDefault ButtonStyle = iota
// ButtonProminent is filled with Accent + accent-foreground text -- the
// primary/default action (e.g. a calculator's operator keys, "OK").
ButtonProminent
// ButtonSecondary is filled with SurfaceAlt -- a muted grey key that sits
// between Default and Prominent (e.g. a calculator's C / +/- / % keys).
ButtonSecondary
// ButtonDanger is a Surface-faced button with a red border + red label -- a
// destructive action (Delete, Remove).
ButtonDanger
)
// dangerInk is the red used for a ButtonDanger's border + label.
var dangerInk = RGB(0xD0, 0x30, 0x30)
// buttonRadius is the corner radius for the button + text-entry body, giving
// the WhiteSur / macOS soft-rectangle look.
const buttonRadius = 6
// accentFg returns the ink to draw on an Accent fill: the theme's
// accent_fg_color when present (GTK/WhiteSur themes set it to white), else a
// safe white fallback.
func accentFg(theme *Theme) RGBA {
if c, ok := theme.Extra["accent_fg_color"]; ok {
return c
}
return RGB(0xFF, 0xFF, 0xFF)
}
// NewButton constructs a Button with the given label + click handler.
// Handler may be nil (a no-op button is still rendered).
func NewButton(label string, onClick func()) *Button {
b := &Button{OnClick: onClick, PressFeedback: true}
b.label = mvvm.NewObservable(label)
return b
}
// Label is the button caption as a shared [mvvm.Observable]: a host binds it (or
// subscribes) instead of touching a field. Lazily created so a bare &Button{}
// works. When Icon is set it is drawn instead of the caption.
func (b *Button) Label() *mvvm.Observable[string] {
if b.label == nil {
b.label = mvvm.NewObservable("")
}
return b.label
}
// Selected is the sticky app-managed "active" state as a shared [mvvm.Observable]:
// the host drives it from its own model (true fills the button with Accent
// regardless of Style); the button never flips it itself. Lazily created.
func (b *Button) Selected() *mvvm.Observable[bool] {
if b.selected == nil {
b.selected = mvvm.NewObservable(false)
}
return b.selected
}
// LeadingIcon is an OPTIONAL vector glyph drawn just before the caption, as a
// shared [mvvm.Observable]: set a non-nil IconFunc to show it, nil to hide it.
// Lazily created; a button that never sets it draws exactly as before.
func (b *Button) LeadingIcon() *mvvm.Observable[IconFunc] {
if b.leadingIcon == nil {
// IconFunc is not comparable, so an equality function is required; funcs
// cannot be compared beyond nil, so every Set is treated as a change.
b.leadingIcon = mvvm.NewObservableEq[IconFunc](nil, func(a, b IconFunc) bool { return false })
}
return b.leadingIcon
}
// Shortcut is an OPTIONAL keyboard-shortcut hint (e.g. "⌘F") drawn right-aligned
// in a muted tone, as a shared [mvvm.Observable]. Empty hides it. Lazily created.
func (b *Button) Shortcut() *mvvm.Observable[string] {
if b.shortcut == nil {
b.shortcut = mvvm.NewObservable("")
}
return b.shortcut
}
// leadingIconFn / shortcutText read the optional adornments without forcing the
// Observable to exist (an unset button keeps the byte-identical legacy draw).
func (b *Button) leadingIconFn() IconFunc {
if b.leadingIcon == nil {
return nil
}
return b.leadingIcon.Get()
}
func (b *Button) shortcutText() string {
if b.shortcut == nil {
return ""
}
return b.shortcut.Get()
}
// SetHovered/SetPressed are wired by the parent container's mouse
// dispatcher so the button can render its hover/press visual states.
// Direct setters (vs deducing from OnEvent kinds) keep the parent
// in control of state propagation -- enter/leave events would
// duplicate the same logic in every leaf widget.
func (b *Button) SetHovered(v bool) { b.hovered = v }
func (b *Button) SetPressed(v bool) { b.pressed = v }
// Draw paints the button through p using theme's palette. Face
// cycles through Surface / SurfaceAlt (hovered) / Accent (pressed);
// the Label is centred in the body using the toolkit's 5x7 bitmap
// font. When the button is pressed the ink swaps to the theme's
// Background so the label stays legible against the Accent face.
func (b *Button) Draw(p painter.Painter, theme *Theme) {
r := b.Bounds()
// Resting face/ink/border from the style.
face := theme.Surface
ink := theme.OnSurface
border := theme.Border
switch b.Style {
case ButtonProminent:
face, ink = theme.Accent, accentFg(theme)
case ButtonSecondary:
face = theme.SurfaceAlt
case ButtonDanger:
ink, border = dangerInk, dangerInk
}
// A sticky selection fills with Accent regardless of style.
if b.Selected().Get() {
face, ink = theme.Accent, accentFg(theme)
}
// Interaction states override the resting fill.
switch {
case b.pressed:
face = theme.Accent
ink = theme.Background
case b.hovered:
face = theme.SurfaceAlt
}
// A disabled button ignores style + interaction and paints a muted face,
// so it reads as inert. Only taken when Disabled, so the enabled draw is
// byte-identical.
if b.Disabled().Get() {
face, ink, border = mutedFace(theme), mutedInk(theme), mutedInk(theme)
}
if b.Flat {
// Square face only; the enclosing container (e.g. ButtonGroup) owns the
// shared border/rounding.
fillRect(p, r.X, r.Y, r.W, r.H, face)
} else {
// buttonRadius routes through scaled so the corner grows with HiDPI and
// touch density; at compact/1x scaled(buttonRadius) == buttonRadius, so the
// drawn corner is byte-identical to before.
rad := scaled(buttonRadius)
fillRoundRect(p, r.X, r.Y, r.W, r.H, rad, face)
strokeRoundRect(p, r.X, r.Y, r.W, r.H, rad, border)
}
// A host-supplied Icon replaces the text Label (like SearchEntry.Icon): it is
// handed the full button rect and the current ink so the glyph tracks the
// button's pressed / disabled tint. Otherwise the caption is drawn, with its
// optional leading icon and trailing shortcut.
if b.Icon != nil {
b.Icon(p, r, ink)
} else {
b.drawContent(p, theme, r, ink)
}
b.drawFocusRing(p, theme, r)
}
// drawContent paints the caption plus its optional leading icon and trailing
// shortcut hint. With neither adornment set the caption is centred exactly as
// before (byte-identical); with either set the row is laid out left-to-right —
// [icon] label … [shortcut] — inside the button's padding.
func (b *Button) drawContent(p painter.Painter, theme *Theme, r Rect, ink RGBA) {
label := b.Label().Get()
icon := b.leadingIconFn()
sc := b.shortcutText()
gh := b.glyphHeight()
ty := r.Y + (r.H-gh)/2
if icon == nil && sc == "" {
if label != "" {
tw := b.textWidth(label)
b.drawText(p, r.X+(r.W-tw)/2, ty, label, ink)
}
return
}
pad := scaled(8)
x := r.X + pad
if icon != nil {
icon(p, Rect{X: x, Y: r.Y + (r.H-gh)/2, W: gh, H: gh}, ink)
x += gh + scaled(6)
}
if label != "" {
b.drawText(p, x, ty, label, ink)
}
if sc != "" {
sw := b.textWidth(sc)
b.drawText(p, r.X+r.W-pad-sw, ty, sc, mutedInk(theme))
}
}
// PreferredWidth is the intrinsic width the button needs to show its CURRENT
// content without clipping — the caption plus its optional leading icon and
// trailing shortcut, measured with the same paddings Draw uses. A host that sizes
// its buttons to this (instead of a hard-coded width) never clips a longer label,
// a wider toggled caption, an icon or a shortcut. Re-measure after a label change.
func (b *Button) PreferredWidth() int {
pad := scaled(8)
// An Icon that REPLACES the caption is square (a glyph in the button's height).
if b.Icon != nil {
return b.glyphHeight() + 2*pad
}
w := 2 * pad
if b.leadingIconFn() != nil {
w += b.glyphHeight() + scaled(6)
}
if label := b.Label().Get(); label != "" {
w += b.textWidth(label)
}
if sc := b.shortcutText(); sc != "" {
w += scaled(12) + b.textWidth(sc) // the gap before the right-aligned hint
}
return w
}
// OnEvent drives the button from pointer events: EventClick presses it (shows
// the pressed face + fires OnClick) and EventMouseUp releases it. Self-managing
// the pressed state means any host that routes the press/release pair gets the
// click feedback for free, without also wiring SetPressed. Other event kinds
// are ignored. (SetPressed remains for hosts that drive press state their own
// way, e.g. enter/leave dispatch.)
func (b *Button) OnEvent(ev Event) {
if b.Disabled().Get() {
return
}
switch ev.Kind {
case EventClick:
if b.PressFeedback {
b.pressed = true
}
b.activate()
case EventKeyDown:
// Enter or Space fires the primary action while focused, reusing the
// exact click path (OnClick) so keyboard + mouse stay consistent. No
// pressed-face flash: there is no routed key-up to clear it.
switch ev.Code {
case "Enter", " ", "Space":
b.activate()
}
case EventMouseUp:
b.pressed = false
case EventMouseMove:
// Raise the hover face when the pointer is over the button, clear it
// when a container forwards a move whose point has left it.
b.hovered = b.localInBounds(ev.X, ev.Y)
}
}
// activate fires OnClick (nil-safe) -- the single mutate+callback path shared
// by an EventClick and an Enter/Space key press so both routes behave
// identically.
func (b *Button) activate() {
if b.OnClick != nil {
b.OnClick()
}
}
// HitRect is the button's interactive rectangle: its drawn Bounds clamped up to
// the density hit-target and centred over them. Byte-identical to Bounds under
// DensityCompact; a small button exposes a >=44px finger target under
// DensityTouch without changing what it draws.
func (b *Button) HitRect() Rect { return touchHitRect(b.Bounds()) }
// HitTest reports whether a surface point falls on the button's (touch-clamped)
// hit rect. At compact the clamp is a pass-through so this equals the default
// Bounds().Contains; at touch the reachable area grows to the finger floor.
func (b *Button) HitTest(px, py int) bool { return b.HitRect().Contains(px, py) }