chore(v2): vendor dependencies for offline/China builds
go mod vendor pins onnxruntime_go v1.12.1, Gio and the rest into v2/vendor so go run/build work without hitting proxy.golang.org (blocked/slow in China). Verified: CGO_ENABLED=1 go build -mod=vendor ./internal/spike and GOOS=windows go build -mod=vendor ./internal/ui both pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
)
|
||||
|
||||
type Bool struct {
|
||||
Value bool
|
||||
|
||||
clk Clickable
|
||||
}
|
||||
|
||||
// Update the widget state and report whether Value was changed.
|
||||
func (b *Bool) Update(gtx layout.Context) bool {
|
||||
changed := false
|
||||
for b.clk.clicked(b, gtx) {
|
||||
b.Value = !b.Value
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Hovered reports whether pointer is over the element.
|
||||
func (b *Bool) Hovered() bool {
|
||||
return b.clk.Hovered()
|
||||
}
|
||||
|
||||
// Pressed reports whether pointer is pressing the element.
|
||||
func (b *Bool) Pressed() bool {
|
||||
return b.clk.Pressed()
|
||||
}
|
||||
|
||||
func (b *Bool) History() []Press {
|
||||
return b.clk.History()
|
||||
}
|
||||
|
||||
func (b *Bool) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
b.Update(gtx)
|
||||
dims := b.clk.layout(b, gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.SelectedOp(b.Value).Add(gtx.Ops)
|
||||
semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops)
|
||||
return w(gtx)
|
||||
})
|
||||
return dims
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
// Border lays out a widget and draws a border inside it.
|
||||
type Border struct {
|
||||
Color color.NRGBA
|
||||
CornerRadius unit.Dp
|
||||
Width unit.Dp
|
||||
}
|
||||
|
||||
func (b Border) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
dims := w(gtx)
|
||||
sz := dims.Size
|
||||
|
||||
rr := gtx.Dp(b.CornerRadius)
|
||||
width := gtx.Dp(b.Width)
|
||||
whalf := (width + 1) / 2
|
||||
sz.X -= whalf * 2
|
||||
sz.Y -= whalf * 2
|
||||
|
||||
r := image.Rectangle{Max: sz}
|
||||
r = r.Add(image.Point{X: whalf, Y: whalf})
|
||||
|
||||
paint.FillShape(gtx.Ops,
|
||||
b.Color,
|
||||
clip.Stroke{
|
||||
Path: clip.UniformRRect(r, rr).Path(gtx.Ops),
|
||||
Width: float32(width),
|
||||
}.Op(),
|
||||
)
|
||||
|
||||
return dims
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
)
|
||||
|
||||
// editBuffer implements a gap buffer for text editing.
|
||||
type editBuffer struct {
|
||||
// The gap start and end in bytes.
|
||||
gapstart, gapend int
|
||||
text []byte
|
||||
|
||||
// changed tracks whether the buffer content
|
||||
// has changed since the last call to Changed.
|
||||
changed bool
|
||||
}
|
||||
|
||||
var _ textSource = (*editBuffer)(nil)
|
||||
|
||||
const minSpace = 5
|
||||
|
||||
func (e *editBuffer) Changed() bool {
|
||||
c := e.changed
|
||||
e.changed = false
|
||||
return c
|
||||
}
|
||||
|
||||
func (e *editBuffer) deleteRunes(caret, count int) (bytes int, runes int) {
|
||||
e.moveGap(caret, 0)
|
||||
for ; count < 0 && e.gapstart > 0; count++ {
|
||||
_, s := utf8.DecodeLastRune(e.text[:e.gapstart])
|
||||
e.gapstart -= s
|
||||
bytes += s
|
||||
runes++
|
||||
e.changed = e.changed || s > 0
|
||||
}
|
||||
for ; count > 0 && e.gapend < len(e.text); count-- {
|
||||
_, s := utf8.DecodeRune(e.text[e.gapend:])
|
||||
e.gapend += s
|
||||
e.changed = e.changed || s > 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// moveGap moves the gap to the caret position. After returning,
|
||||
// the gap is guaranteed to be at least space bytes long.
|
||||
func (e *editBuffer) moveGap(caret, space int) {
|
||||
if e.gapLen() < space {
|
||||
if space < minSpace {
|
||||
space = minSpace
|
||||
}
|
||||
txt := make([]byte, int(e.Size())+space)
|
||||
// Expand to capacity.
|
||||
txt = txt[:cap(txt)]
|
||||
gaplen := len(txt) - int(e.Size())
|
||||
if caret > e.gapstart {
|
||||
copy(txt, e.text[:e.gapstart])
|
||||
copy(txt[caret+gaplen:], e.text[caret:])
|
||||
copy(txt[e.gapstart:], e.text[e.gapend:caret+e.gapLen()])
|
||||
} else {
|
||||
copy(txt, e.text[:caret])
|
||||
copy(txt[e.gapstart+gaplen:], e.text[e.gapend:])
|
||||
copy(txt[caret+gaplen:], e.text[caret:e.gapstart])
|
||||
}
|
||||
e.text = txt
|
||||
e.gapstart = caret
|
||||
e.gapend = e.gapstart + gaplen
|
||||
} else {
|
||||
if caret > e.gapstart {
|
||||
copy(e.text[e.gapstart:], e.text[e.gapend:caret+e.gapLen()])
|
||||
} else {
|
||||
copy(e.text[caret+e.gapLen():], e.text[caret:e.gapstart])
|
||||
}
|
||||
l := e.gapLen()
|
||||
e.gapstart = caret
|
||||
e.gapend = e.gapstart + l
|
||||
}
|
||||
}
|
||||
|
||||
func (e *editBuffer) Size() int64 {
|
||||
return int64(len(e.text) - e.gapLen())
|
||||
}
|
||||
|
||||
func (e *editBuffer) gapLen() int {
|
||||
return e.gapend - e.gapstart
|
||||
}
|
||||
|
||||
func (e *editBuffer) ReadAt(p []byte, offset int64) (int, error) {
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if offset == e.Size() {
|
||||
return 0, io.EOF
|
||||
}
|
||||
var total int
|
||||
if offset < int64(e.gapstart) {
|
||||
n := copy(p, e.text[offset:e.gapstart])
|
||||
p = p[n:]
|
||||
total += n
|
||||
offset += int64(n)
|
||||
}
|
||||
if offset >= int64(e.gapstart) {
|
||||
n := copy(p, e.text[offset+int64(e.gapLen()):])
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (e *editBuffer) ReplaceRunes(byteOffset, runeCount int64, s string) {
|
||||
e.deleteRunes(int(byteOffset), int(runeCount))
|
||||
e.prepend(int(byteOffset), s)
|
||||
}
|
||||
|
||||
func (e *editBuffer) prepend(caret int, s string) {
|
||||
if !utf8.ValidString(s) {
|
||||
s = runes.ReplaceIllFormed().String(s)
|
||||
}
|
||||
|
||||
e.moveGap(caret, len(s))
|
||||
copy(e.text[caret:], s)
|
||||
e.gapstart += len(s)
|
||||
e.changed = e.changed || len(s) > 0
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
"time"
|
||||
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
)
|
||||
|
||||
// Clickable represents a clickable area.
|
||||
type Clickable struct {
|
||||
click gesture.Click
|
||||
history []Press
|
||||
|
||||
requestClicks int
|
||||
pressedKey key.Name
|
||||
}
|
||||
|
||||
// Click represents a click.
|
||||
type Click struct {
|
||||
Modifiers key.Modifiers
|
||||
NumClicks int
|
||||
}
|
||||
|
||||
// Press represents a past pointer press.
|
||||
type Press struct {
|
||||
// Position of the press.
|
||||
Position image.Point
|
||||
// Start is when the press began.
|
||||
Start time.Time
|
||||
// End is when the press was ended by a release or cancel.
|
||||
// A zero End means it hasn't ended yet.
|
||||
End time.Time
|
||||
// Cancelled is true for cancelled presses.
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// Click executes a simple programmatic click.
|
||||
func (b *Clickable) Click() {
|
||||
b.requestClicks++
|
||||
}
|
||||
|
||||
// Clicked calls Update and reports whether a click was registered.
|
||||
func (b *Clickable) Clicked(gtx layout.Context) bool {
|
||||
return b.clicked(b, gtx)
|
||||
}
|
||||
|
||||
func (b *Clickable) clicked(t event.Tag, gtx layout.Context) bool {
|
||||
_, clicked := b.update(t, gtx)
|
||||
return clicked
|
||||
}
|
||||
|
||||
// Hovered reports whether a pointer is over the element.
|
||||
func (b *Clickable) Hovered() bool {
|
||||
return b.click.Hovered()
|
||||
}
|
||||
|
||||
// Pressed reports whether a pointer is pressing the element.
|
||||
func (b *Clickable) Pressed() bool {
|
||||
return b.click.Pressed()
|
||||
}
|
||||
|
||||
// History is the past pointer presses useful for drawing markers.
|
||||
// History is retained for a short duration (about a second).
|
||||
func (b *Clickable) History() []Press {
|
||||
return b.history
|
||||
}
|
||||
|
||||
// Layout and update the button state.
|
||||
func (b *Clickable) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
return b.layout(b, gtx, w)
|
||||
}
|
||||
|
||||
func (b *Clickable) layout(t event.Tag, gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
for {
|
||||
_, ok := b.update(t, gtx)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
m := op.Record(gtx.Ops)
|
||||
dims := w(gtx)
|
||||
c := m.Stop()
|
||||
defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
|
||||
semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops)
|
||||
b.click.Add(gtx.Ops)
|
||||
event.Op(gtx.Ops, t)
|
||||
c.Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
|
||||
// Update the button state by processing events, and return the next
|
||||
// click, if any.
|
||||
func (b *Clickable) Update(gtx layout.Context) (Click, bool) {
|
||||
return b.update(b, gtx)
|
||||
}
|
||||
|
||||
func (b *Clickable) update(t event.Tag, gtx layout.Context) (Click, bool) {
|
||||
for len(b.history) > 0 {
|
||||
c := b.history[0]
|
||||
if c.End.IsZero() || gtx.Now.Sub(c.End) < 1*time.Second {
|
||||
break
|
||||
}
|
||||
n := copy(b.history, b.history[1:])
|
||||
b.history = b.history[:n]
|
||||
}
|
||||
if c := b.requestClicks; c > 0 {
|
||||
b.requestClicks = 0
|
||||
return Click{
|
||||
NumClicks: c,
|
||||
}, true
|
||||
}
|
||||
for {
|
||||
e, ok := b.click.Update(gtx.Source)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e.Kind {
|
||||
case gesture.KindClick:
|
||||
if l := len(b.history); l > 0 {
|
||||
b.history[l-1].End = gtx.Now
|
||||
}
|
||||
return Click{
|
||||
Modifiers: e.Modifiers,
|
||||
NumClicks: e.NumClicks,
|
||||
}, true
|
||||
case gesture.KindCancel:
|
||||
for i := range b.history {
|
||||
b.history[i].Cancelled = true
|
||||
if b.history[i].End.IsZero() {
|
||||
b.history[i].End = gtx.Now
|
||||
}
|
||||
}
|
||||
case gesture.KindPress:
|
||||
b.history = append(b.history, Press{
|
||||
Position: e.Position,
|
||||
Start: gtx.Now,
|
||||
})
|
||||
}
|
||||
}
|
||||
for {
|
||||
e, ok := gtx.Event(
|
||||
key.FocusFilter{Target: t},
|
||||
key.Filter{Focus: t, Name: key.NameReturn},
|
||||
key.Filter{Focus: t, Name: key.NameSpace},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case key.FocusEvent:
|
||||
if e.Focus {
|
||||
b.pressedKey = ""
|
||||
}
|
||||
case key.Event:
|
||||
if !gtx.Focused(t) {
|
||||
break
|
||||
}
|
||||
if e.Name != key.NameReturn && e.Name != key.NameSpace {
|
||||
break
|
||||
}
|
||||
switch e.State {
|
||||
case key.Press:
|
||||
b.pressedKey = e.Name
|
||||
case key.Release:
|
||||
if b.pressedKey != e.Name {
|
||||
break
|
||||
}
|
||||
// only register a key as a click if the key was pressed and released while this button was focused
|
||||
b.pressedKey = ""
|
||||
return Click{
|
||||
Modifiers: e.Modifiers,
|
||||
NumClicks: 1,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Click{}, false
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/bits"
|
||||
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
)
|
||||
|
||||
// Decorations handles the states of window decorations.
|
||||
type Decorations struct {
|
||||
// Maximized controls the look and behaviour of the maximize
|
||||
// button. It is the user's responsibility to set Maximized
|
||||
// according to the window state reported through [app.ConfigEvent].
|
||||
Maximized bool
|
||||
clicks map[int]*Clickable
|
||||
}
|
||||
|
||||
// LayoutMove lays out the widget that makes a window movable.
|
||||
func (d *Decorations) LayoutMove(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
dims := w(gtx)
|
||||
defer clip.Rect{Max: dims.Size}.Push(gtx.Ops).Pop()
|
||||
system.ActionInputOp(system.ActionMove).Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
|
||||
// Clickable returns the clickable for the given single action.
|
||||
func (d *Decorations) Clickable(action system.Action) *Clickable {
|
||||
if bits.OnesCount(uint(action)) != 1 {
|
||||
panic(fmt.Errorf("not a single action"))
|
||||
}
|
||||
idx := bits.TrailingZeros(uint(action))
|
||||
click, found := d.clicks[idx]
|
||||
if !found {
|
||||
click = new(Clickable)
|
||||
if d.clicks == nil {
|
||||
d.clicks = make(map[int]*Clickable)
|
||||
}
|
||||
d.clicks[idx] = click
|
||||
}
|
||||
return click
|
||||
}
|
||||
|
||||
// Update the state and return the set of actions activated by the user.
|
||||
func (d *Decorations) Update(gtx layout.Context) system.Action {
|
||||
var actions system.Action
|
||||
for idx, clk := range d.clicks {
|
||||
if !clk.Clicked(gtx) {
|
||||
continue
|
||||
}
|
||||
action := system.Action(1 << idx)
|
||||
switch {
|
||||
case action == system.ActionMaximize && d.Maximized:
|
||||
action = system.ActionUnmaximize
|
||||
case action == system.ActionUnmaximize && !d.Maximized:
|
||||
action = system.ActionMaximize
|
||||
}
|
||||
actions |= action
|
||||
}
|
||||
return actions
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
)
|
||||
|
||||
// Draggable makes a widget draggable.
|
||||
type Draggable struct {
|
||||
// Type contains the MIME type and matches transfer.SourceOp.
|
||||
Type string
|
||||
|
||||
drag gesture.Drag
|
||||
click f32.Point
|
||||
pos f32.Point
|
||||
}
|
||||
|
||||
func (d *Draggable) Layout(gtx layout.Context, w, drag layout.Widget) layout.Dimensions {
|
||||
if !gtx.Enabled() {
|
||||
return w(gtx)
|
||||
}
|
||||
dims := w(gtx)
|
||||
|
||||
stack := clip.Rect{Max: dims.Size}.Push(gtx.Ops)
|
||||
d.drag.Add(gtx.Ops)
|
||||
event.Op(gtx.Ops, d)
|
||||
stack.Pop()
|
||||
|
||||
if drag != nil && d.drag.Pressed() {
|
||||
rec := op.Record(gtx.Ops)
|
||||
op.Offset(d.pos.Round()).Add(gtx.Ops)
|
||||
drag(gtx)
|
||||
op.Defer(gtx.Ops, rec.Stop())
|
||||
}
|
||||
|
||||
return dims
|
||||
}
|
||||
|
||||
// Dragging returns whether d is being dragged.
|
||||
func (d *Draggable) Dragging() bool {
|
||||
return d.drag.Dragging()
|
||||
}
|
||||
|
||||
// Update the draggable and returns the MIME type for which the Draggable was
|
||||
// requested to offer data, if any
|
||||
func (d *Draggable) Update(gtx layout.Context) (mime string, requested bool) {
|
||||
pos := d.pos
|
||||
for {
|
||||
ev, ok := d.drag.Update(gtx.Metric, gtx.Source, gesture.Both)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch ev.Kind {
|
||||
case pointer.Press:
|
||||
d.click = ev.Position
|
||||
pos = f32.Point{}
|
||||
case pointer.Drag, pointer.Release:
|
||||
pos = ev.Position.Sub(d.click)
|
||||
}
|
||||
}
|
||||
d.pos = pos
|
||||
|
||||
for {
|
||||
e, ok := gtx.Event(transfer.SourceFilter{Target: d, Type: d.Type})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if e, ok := e.(transfer.RequestEvent); ok {
|
||||
return e.Type, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Offer the data ready for a drop. Must be called after being Requested.
|
||||
// The mime must be one in the requested list.
|
||||
func (d *Draggable) Offer(gtx layout.Context, mime string, data io.ReadCloser) {
|
||||
gtx.Execute(transfer.OfferCmd{Tag: d, Type: mime, Data: data})
|
||||
}
|
||||
|
||||
// Pos returns the drag position relative to its initial click position.
|
||||
func (d *Draggable) Pos() f32.Point {
|
||||
return d.pos
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
// Package widget implements state tracking and event handling of
|
||||
// common user interface controls. To draw widgets, use a theme
|
||||
// packages such as package [gioui.org/widget/material].
|
||||
package widget
|
||||
+1144
File diff suppressed because it is too large
Load Diff
+139
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
)
|
||||
|
||||
type Enum struct {
|
||||
Value string
|
||||
hovered string
|
||||
hovering bool
|
||||
|
||||
focus string
|
||||
focused bool
|
||||
|
||||
keys []*enumKey
|
||||
}
|
||||
|
||||
type enumKey struct {
|
||||
key string
|
||||
click gesture.Click
|
||||
tag struct{}
|
||||
}
|
||||
|
||||
func (e *Enum) index(k string) *enumKey {
|
||||
for _, v := range e.keys {
|
||||
if v.key == k {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update the state and report whether Value has changed by user interaction.
|
||||
func (e *Enum) Update(gtx layout.Context) bool {
|
||||
if !gtx.Enabled() {
|
||||
e.focused = false
|
||||
}
|
||||
e.hovering = false
|
||||
changed := false
|
||||
for _, state := range e.keys {
|
||||
for {
|
||||
ev, ok := state.click.Update(gtx.Source)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch ev.Kind {
|
||||
case gesture.KindPress:
|
||||
if ev.Source == pointer.Mouse {
|
||||
gtx.Execute(key.FocusCmd{Tag: &state.tag})
|
||||
}
|
||||
case gesture.KindClick:
|
||||
if state.key != e.Value {
|
||||
e.Value = state.key
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for {
|
||||
ev, ok := gtx.Event(
|
||||
key.FocusFilter{Target: &state.tag},
|
||||
key.Filter{Focus: &state.tag, Name: key.NameReturn},
|
||||
key.Filter{Focus: &state.tag, Name: key.NameSpace},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch ev := ev.(type) {
|
||||
case key.FocusEvent:
|
||||
if ev.Focus {
|
||||
e.focused = true
|
||||
e.focus = state.key
|
||||
} else if state.key == e.focus {
|
||||
e.focused = false
|
||||
}
|
||||
case key.Event:
|
||||
if ev.State != key.Release {
|
||||
break
|
||||
}
|
||||
if ev.Name != key.NameReturn && ev.Name != key.NameSpace {
|
||||
break
|
||||
}
|
||||
if state.key != e.Value {
|
||||
e.Value = state.key
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.click.Hovered() {
|
||||
e.hovered = state.key
|
||||
e.hovering = true
|
||||
}
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// Hovered returns the key that is highlighted, or false if none are.
|
||||
func (e *Enum) Hovered() (string, bool) {
|
||||
return e.hovered, e.hovering
|
||||
}
|
||||
|
||||
// Focused reports the focused key, or false if no key is focused.
|
||||
func (e *Enum) Focused() (string, bool) {
|
||||
return e.focus, e.focused
|
||||
}
|
||||
|
||||
// Layout adds the event handler for the key k.
|
||||
func (e *Enum) Layout(gtx layout.Context, k string, content layout.Widget) layout.Dimensions {
|
||||
e.Update(gtx)
|
||||
m := op.Record(gtx.Ops)
|
||||
dims := content(gtx)
|
||||
c := m.Stop()
|
||||
defer clip.Rect{Max: dims.Size}.Push(gtx.Ops).Pop()
|
||||
|
||||
state := e.index(k)
|
||||
if state == nil {
|
||||
state = &enumKey{
|
||||
key: k,
|
||||
}
|
||||
e.keys = append(e.keys, state)
|
||||
}
|
||||
clk := &state.click
|
||||
clk.Add(gtx.Ops)
|
||||
event.Op(gtx.Ops, &state.tag)
|
||||
semantic.SelectedOp(k == e.Value).Add(gtx.Ops)
|
||||
semantic.EnabledOp(gtx.Enabled()).Add(gtx.Ops)
|
||||
c.Add(gtx.Ops)
|
||||
|
||||
return dims
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/layout"
|
||||
)
|
||||
|
||||
// Fit scales a widget to fit and clip to the constraints.
|
||||
type Fit uint8
|
||||
|
||||
const (
|
||||
// Unscaled does not alter the scale of a widget.
|
||||
Unscaled Fit = iota
|
||||
// Contain scales widget as large as possible without cropping
|
||||
// and it preserves aspect-ratio.
|
||||
Contain
|
||||
// Cover scales the widget to cover the constraint area and
|
||||
// preserves aspect-ratio.
|
||||
Cover
|
||||
// ScaleDown scales the widget smaller without cropping,
|
||||
// when it exceeds the constraint area.
|
||||
// It preserves aspect-ratio.
|
||||
ScaleDown
|
||||
// Fill stretches the widget to the constraints and does not
|
||||
// preserve aspect-ratio.
|
||||
Fill
|
||||
)
|
||||
|
||||
// scale computes the new dimensions and transformation required to fit dims to cs, given the position.
|
||||
func (fit Fit) scale(cs layout.Constraints, pos layout.Direction, dims layout.Dimensions) (layout.Dimensions, f32.Affine2D) {
|
||||
widgetSize := dims.Size
|
||||
|
||||
if fit == Unscaled || dims.Size.X == 0 || dims.Size.Y == 0 {
|
||||
dims.Size = cs.Constrain(dims.Size)
|
||||
|
||||
offset := pos.Position(widgetSize, dims.Size)
|
||||
dims.Baseline += offset.Y
|
||||
return dims, f32.AffineId().Offset(layout.FPt(offset))
|
||||
}
|
||||
|
||||
scale := f32.Point{
|
||||
X: float32(cs.Max.X) / float32(dims.Size.X),
|
||||
Y: float32(cs.Max.Y) / float32(dims.Size.Y),
|
||||
}
|
||||
|
||||
switch fit {
|
||||
case Contain:
|
||||
if scale.Y < scale.X {
|
||||
scale.X = scale.Y
|
||||
} else {
|
||||
scale.Y = scale.X
|
||||
}
|
||||
case Cover:
|
||||
if scale.Y > scale.X {
|
||||
scale.X = scale.Y
|
||||
} else {
|
||||
scale.Y = scale.X
|
||||
}
|
||||
case ScaleDown:
|
||||
if scale.Y < scale.X {
|
||||
scale.X = scale.Y
|
||||
} else {
|
||||
scale.Y = scale.X
|
||||
}
|
||||
|
||||
// The widget would need to be scaled up, no change needed.
|
||||
if scale.X >= 1 {
|
||||
dims.Size = cs.Constrain(dims.Size)
|
||||
|
||||
offset := pos.Position(widgetSize, dims.Size)
|
||||
dims.Baseline += offset.Y
|
||||
return dims, f32.AffineId().Offset(layout.FPt(offset))
|
||||
}
|
||||
case Fill:
|
||||
}
|
||||
|
||||
var scaledSize image.Point
|
||||
scaledSize.X = int(float32(widgetSize.X) * scale.X)
|
||||
scaledSize.Y = int(float32(widgetSize.Y) * scale.Y)
|
||||
dims.Size = cs.Constrain(scaledSize)
|
||||
dims.Baseline = int(float32(dims.Baseline) * scale.Y)
|
||||
|
||||
offset := pos.Position(scaledSize, dims.Size)
|
||||
trans := f32.AffineId().
|
||||
Scale(f32.Point{}, scale).
|
||||
Offset(layout.FPt(offset))
|
||||
|
||||
dims.Baseline += offset.Y
|
||||
|
||||
return dims, trans
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
// Float is for selecting a value in a range.
|
||||
type Float struct {
|
||||
// Value is the value of the Float, in the [0; 1] range.
|
||||
Value float32
|
||||
|
||||
drag gesture.Drag
|
||||
axis layout.Axis
|
||||
length float32
|
||||
}
|
||||
|
||||
// Dragging returns whether the value is being interacted with.
|
||||
func (f *Float) Dragging() bool { return f.drag.Dragging() }
|
||||
|
||||
func (f *Float) Layout(gtx layout.Context, axis layout.Axis, pointerMargin unit.Dp) layout.Dimensions {
|
||||
f.Update(gtx)
|
||||
size := gtx.Constraints.Min
|
||||
f.length = float32(axis.Convert(size).X)
|
||||
f.axis = axis
|
||||
|
||||
margin := axis.Convert(image.Pt(gtx.Dp(pointerMargin), 0))
|
||||
rect := image.Rectangle{
|
||||
Min: margin.Mul(-1),
|
||||
Max: size.Add(margin),
|
||||
}
|
||||
defer clip.Rect(rect).Push(gtx.Ops).Pop()
|
||||
f.drag.Add(gtx.Ops)
|
||||
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
|
||||
// Update the Value according to drag events along the f's main axis.
|
||||
// The return value reports whether the value was changed.
|
||||
//
|
||||
// The range of f is set by the minimum constraints main axis value.
|
||||
func (f *Float) Update(gtx layout.Context) bool {
|
||||
changed := false
|
||||
for {
|
||||
e, ok := f.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(f.axis))
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if f.length > 0 && (e.Kind == pointer.Press || e.Kind == pointer.Drag) {
|
||||
pos := e.Position.X
|
||||
if f.axis == layout.Vertical {
|
||||
pos = f.length - e.Position.Y
|
||||
}
|
||||
f.Value = pos / f.length
|
||||
if f.Value < 0 {
|
||||
f.Value = 0
|
||||
} else if f.Value > 1 {
|
||||
f.Value = 1
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
|
||||
"golang.org/x/exp/shiny/iconvg"
|
||||
)
|
||||
|
||||
type Icon struct {
|
||||
src []byte
|
||||
// Cached values.
|
||||
op paint.ImageOp
|
||||
imgSize int
|
||||
imgColor color.NRGBA
|
||||
}
|
||||
|
||||
const defaultIconSize = unit.Dp(24)
|
||||
|
||||
// NewIcon returns a new Icon from IconVG data.
|
||||
func NewIcon(data []byte) (*Icon, error) {
|
||||
_, err := iconvg.DecodeMetadata(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Icon{src: data}, nil
|
||||
}
|
||||
|
||||
// Layout displays the icon with its size set to the X minimum constraint.
|
||||
func (ic *Icon) Layout(gtx layout.Context, color color.NRGBA) layout.Dimensions {
|
||||
sz := gtx.Constraints.Min.X
|
||||
if sz == 0 {
|
||||
sz = gtx.Dp(defaultIconSize)
|
||||
}
|
||||
size := gtx.Constraints.Constrain(image.Pt(sz, sz))
|
||||
defer clip.Rect{Max: size}.Push(gtx.Ops).Pop()
|
||||
|
||||
ico := ic.image(size.X, color)
|
||||
ico.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
return layout.Dimensions{
|
||||
Size: ico.Size(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ic *Icon) image(sz int, color color.NRGBA) paint.ImageOp {
|
||||
if sz == ic.imgSize && color == ic.imgColor {
|
||||
return ic.op
|
||||
}
|
||||
m, _ := iconvg.DecodeMetadata(ic.src)
|
||||
dx, dy := m.ViewBox.AspectRatio()
|
||||
img := image.NewRGBA(image.Rectangle{Max: image.Point{X: sz, Y: int(float32(sz) * dy / dx)}})
|
||||
var ico iconvg.Rasterizer
|
||||
ico.SetDstImage(img, img.Bounds(), draw.Src)
|
||||
m.Palette[0] = f32color.NRGBAToLinearRGBA(color)
|
||||
iconvg.Decode(&ico, ic.src, &iconvg.DecodeOptions{
|
||||
Palette: &m.Palette,
|
||||
})
|
||||
ic.op = paint.NewImageOp(img)
|
||||
ic.imgSize = sz
|
||||
ic.imgColor = color
|
||||
return ic.op
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
// Image is a widget that displays an image.
|
||||
type Image struct {
|
||||
// Src is the image to display.
|
||||
Src paint.ImageOp
|
||||
// Fit specifies how to scale the image to the constraints.
|
||||
// By default it does not do any scaling.
|
||||
Fit Fit
|
||||
// Position specifies where to position the image within
|
||||
// the constraints.
|
||||
Position layout.Direction
|
||||
// Scale is the factor used for converting image pixels to dp.
|
||||
// If Scale is zero it defaults to 1.
|
||||
//
|
||||
// To map one image pixel to one output pixel, set Scale to 1.0 / gtx.Metric.PxPerDp.
|
||||
Scale float32
|
||||
}
|
||||
|
||||
func (im Image) Layout(gtx layout.Context) layout.Dimensions {
|
||||
scale := im.Scale
|
||||
if scale == 0 {
|
||||
scale = 1
|
||||
}
|
||||
|
||||
size := im.Src.Size()
|
||||
wf, hf := float32(size.X), float32(size.Y)
|
||||
w, h := gtx.Dp(unit.Dp(wf*scale)), gtx.Dp(unit.Dp(hf*scale))
|
||||
|
||||
dims, trans := im.Fit.scale(gtx.Constraints, im.Position, layout.Dimensions{Size: image.Pt(w, h)})
|
||||
defer clip.Rect{Max: dims.Size}.Push(gtx.Ops).Pop()
|
||||
|
||||
pixelScale := scale * gtx.Metric.PxPerDp
|
||||
trans = trans.Mul(f32.AffineId().Scale(f32.Point{}, f32.Pt(pixelScale, pixelScale)))
|
||||
defer op.Affine(trans).Push(gtx.Ops).Pop()
|
||||
|
||||
im.Src.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
|
||||
return dims
|
||||
}
|
||||
+537
@@ -0,0 +1,537 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"image"
|
||||
"io"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"gioui.org/text"
|
||||
"github.com/go-text/typesetting/segmenter"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
type lineInfo struct {
|
||||
xOff fixed.Int26_6
|
||||
yOff int
|
||||
width fixed.Int26_6
|
||||
ascent, descent fixed.Int26_6
|
||||
glyphs int
|
||||
}
|
||||
|
||||
func (l lineInfo) getLineEnd() fixed.Int26_6 {
|
||||
return l.xOff + l.width
|
||||
}
|
||||
|
||||
type glyphIndex struct {
|
||||
// glyphs holds the glyphs processed.
|
||||
glyphs []text.Glyph
|
||||
// positions contain all possible caret positions, sorted by rune index.
|
||||
positions []combinedPos
|
||||
// lines contains metadata about the size and position of each line of
|
||||
// text.
|
||||
lines []lineInfo
|
||||
|
||||
// currentLineMin and currentLineMax track the dimensions of the line
|
||||
// that is being indexed.
|
||||
currentLineMin, currentLineMax fixed.Int26_6
|
||||
// currentLineGlyphs tracks how many glyphs are contained within the
|
||||
// line that is being indexed.
|
||||
currentLineGlyphs int
|
||||
// pos tracks attributes of the next valid cursor position within the indexed
|
||||
// text.
|
||||
pos combinedPos
|
||||
// prog tracks the current glyph text progression to detect bidi changes.
|
||||
prog text.Flags
|
||||
// clusterAdvance accumulates the advances of glyphs in a glyph cluster.
|
||||
clusterAdvance fixed.Int26_6
|
||||
// truncated indicates that the text was truncated by the shaper.
|
||||
truncated bool
|
||||
// midCluster tracks whether the next glyph processed is not the first glyph in a
|
||||
// cluster.
|
||||
midCluster bool
|
||||
}
|
||||
|
||||
// reset prepares the index for reuse.
|
||||
func (g *glyphIndex) reset() {
|
||||
g.glyphs = g.glyphs[:0]
|
||||
g.positions = g.positions[:0]
|
||||
g.lines = g.lines[:0]
|
||||
g.currentLineMin = 0
|
||||
g.currentLineMax = 0
|
||||
g.currentLineGlyphs = 0
|
||||
g.pos = combinedPos{}
|
||||
g.prog = 0
|
||||
g.clusterAdvance = 0
|
||||
g.truncated = false
|
||||
g.midCluster = false
|
||||
}
|
||||
|
||||
// screenPos represents a character position in text line and column numbers,
|
||||
// not pixels.
|
||||
type screenPos struct {
|
||||
// col is the column, measured in runes.
|
||||
// FIXME: we only ever use col for start or end of lines.
|
||||
// We don't need accurate accounting, so can we get rid of it?
|
||||
col int
|
||||
line int
|
||||
}
|
||||
|
||||
// combinedPos is a point in the editor.
|
||||
type combinedPos struct {
|
||||
// runes is the offset in runes.
|
||||
runes int
|
||||
|
||||
lineCol screenPos
|
||||
|
||||
// Pixel coordinates
|
||||
x fixed.Int26_6
|
||||
y int
|
||||
|
||||
ascent, descent fixed.Int26_6
|
||||
|
||||
// runIndex tracks which run this position is within, counted each time
|
||||
// the index processes an end of run marker.
|
||||
runIndex int
|
||||
// towardOrigin tracks whether this glyph's run is progressing toward the
|
||||
// origin or away from it.
|
||||
towardOrigin bool
|
||||
}
|
||||
|
||||
// incrementPosition returns the next position after pos (if any). Pos _must_ be
|
||||
// an unmodified position acquired from one of the closest* methods. If eof is
|
||||
// true, there was no next position.
|
||||
func (g *glyphIndex) incrementPosition(pos combinedPos) (next combinedPos, eof bool) {
|
||||
candidate, index := g.closestToRune(pos.runes)
|
||||
for candidate != pos && index+1 < len(g.positions) {
|
||||
index++
|
||||
candidate = g.positions[index]
|
||||
}
|
||||
if index+1 < len(g.positions) {
|
||||
return g.positions[index+1], false
|
||||
}
|
||||
return candidate, true
|
||||
}
|
||||
|
||||
func (g *glyphIndex) insertPosition(pos combinedPos) {
|
||||
lastIdx := len(g.positions) - 1
|
||||
if lastIdx >= 0 {
|
||||
lastPos := g.positions[lastIdx]
|
||||
if lastPos.runes == pos.runes && (lastPos.y != pos.y || (lastPos.x == pos.x)) {
|
||||
// If we insert a consecutive position with the same logical position,
|
||||
// overwrite the previous position with the new one.
|
||||
g.positions[lastIdx] = pos
|
||||
return
|
||||
}
|
||||
}
|
||||
g.positions = append(g.positions, pos)
|
||||
}
|
||||
|
||||
// Glyph indexes the provided glyph, generating text cursor positions for it.
|
||||
func (g *glyphIndex) Glyph(gl text.Glyph) {
|
||||
g.glyphs = append(g.glyphs, gl)
|
||||
g.currentLineGlyphs++
|
||||
if len(g.positions) == 0 {
|
||||
// First-iteration setup.
|
||||
g.currentLineMin = math.MaxInt32
|
||||
g.currentLineMax = 0
|
||||
}
|
||||
if gl.X < g.currentLineMin {
|
||||
g.currentLineMin = gl.X
|
||||
}
|
||||
if end := gl.X + gl.Advance; end > g.currentLineMax {
|
||||
g.currentLineMax = end
|
||||
}
|
||||
|
||||
needsNewLine := gl.Flags&text.FlagLineBreak != 0
|
||||
needsNewRun := gl.Flags&text.FlagRunBreak != 0
|
||||
breaksParagraph := gl.Flags&text.FlagParagraphBreak != 0
|
||||
breaksCluster := gl.Flags&text.FlagClusterBreak != 0
|
||||
// We should insert new positions if the glyph we're processing terminates
|
||||
// a glyph cluster, has nonzero runes, and is not a hard newline.
|
||||
insertPositionsWithin := breaksCluster && !breaksParagraph && gl.Runes > 0
|
||||
|
||||
// Get the text progression/direction right.
|
||||
g.prog = gl.Flags & text.FlagTowardOrigin
|
||||
g.pos.towardOrigin = g.prog == text.FlagTowardOrigin
|
||||
if !g.midCluster {
|
||||
// Create the text position prior to the glyph.
|
||||
g.pos.x = gl.X
|
||||
g.pos.y = int(gl.Y)
|
||||
g.pos.ascent = gl.Ascent
|
||||
g.pos.descent = gl.Descent
|
||||
if g.pos.towardOrigin {
|
||||
g.pos.x += gl.Advance
|
||||
}
|
||||
g.insertPosition(g.pos)
|
||||
}
|
||||
|
||||
g.midCluster = !breaksCluster
|
||||
|
||||
if breaksParagraph {
|
||||
// Paragraph breaking clusters shouldn't have positions generated for both
|
||||
// sides of them. They're always zero-width, so doing so would
|
||||
// create two visually identical cursor positions. Just reset
|
||||
// cluster state, increment by their runes, and move on to the
|
||||
// next glyph.
|
||||
g.clusterAdvance = 0
|
||||
g.pos.runes += int(gl.Runes)
|
||||
}
|
||||
// Always track the cumulative advance added by the glyph, even if it
|
||||
// doesn't terminate a cluster itself.
|
||||
g.clusterAdvance += gl.Advance
|
||||
if insertPositionsWithin {
|
||||
// Construct the text positions _within_ gl.
|
||||
g.pos.y = int(gl.Y)
|
||||
g.pos.ascent = gl.Ascent
|
||||
g.pos.descent = gl.Descent
|
||||
width := g.clusterAdvance
|
||||
positionCount := int(gl.Runes)
|
||||
runesPerPosition := 1
|
||||
if gl.Flags&text.FlagTruncator != 0 {
|
||||
// Treat the truncator as a single unit that is either selected or not.
|
||||
positionCount = 1
|
||||
runesPerPosition = int(gl.Runes)
|
||||
g.truncated = true
|
||||
}
|
||||
perRune := width / fixed.Int26_6(positionCount)
|
||||
adjust := fixed.Int26_6(0)
|
||||
if g.pos.towardOrigin {
|
||||
// If RTL, subtract increments from the width of the cluster
|
||||
// instead of adding.
|
||||
adjust = width
|
||||
perRune = -perRune
|
||||
}
|
||||
for i := 1; i <= positionCount; i++ {
|
||||
g.pos.x = gl.X + adjust + perRune*fixed.Int26_6(i)
|
||||
g.pos.runes += runesPerPosition
|
||||
g.pos.lineCol.col += runesPerPosition
|
||||
g.insertPosition(g.pos)
|
||||
}
|
||||
g.clusterAdvance = 0
|
||||
}
|
||||
if needsNewRun {
|
||||
g.pos.runIndex++
|
||||
}
|
||||
if needsNewLine {
|
||||
g.lines = append(g.lines, lineInfo{
|
||||
xOff: g.currentLineMin,
|
||||
yOff: int(gl.Y),
|
||||
width: g.currentLineMax - g.currentLineMin,
|
||||
ascent: g.positions[len(g.positions)-1].ascent,
|
||||
descent: g.positions[len(g.positions)-1].descent,
|
||||
glyphs: g.currentLineGlyphs,
|
||||
})
|
||||
g.pos.lineCol.line++
|
||||
g.pos.lineCol.col = 0
|
||||
g.pos.runIndex = 0
|
||||
g.currentLineMin = math.MaxInt32
|
||||
g.currentLineMax = 0
|
||||
g.currentLineGlyphs = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (g *glyphIndex) closestToRune(runeIdx int) (combinedPos, int) {
|
||||
n := len(g.positions)
|
||||
if n == 0 {
|
||||
return combinedPos{}, 0
|
||||
}
|
||||
i := sort.Search(n, func(i int) bool {
|
||||
pos := g.positions[i]
|
||||
return pos.runes >= runeIdx
|
||||
})
|
||||
|
||||
notFound := i == n
|
||||
if notFound {
|
||||
return g.positions[n-1], n - 1
|
||||
}
|
||||
return g.positions[i], i
|
||||
}
|
||||
|
||||
func (g *glyphIndex) closestToLineCol(lineCol screenPos) combinedPos {
|
||||
n := len(g.positions)
|
||||
if n == 0 {
|
||||
return combinedPos{}
|
||||
}
|
||||
i := sort.Search(n, func(i int) bool {
|
||||
pos := g.positions[i]
|
||||
return pos.lineCol.line > lineCol.line || (pos.lineCol.line == lineCol.line && pos.lineCol.col >= lineCol.col)
|
||||
})
|
||||
notFound := i == n
|
||||
if notFound {
|
||||
return g.positions[n-1]
|
||||
}
|
||||
pos := g.positions[i]
|
||||
foundInNextLine := pos.lineCol.line > lineCol.line
|
||||
if foundInNextLine && i > 0 {
|
||||
prior := g.positions[i-1]
|
||||
prior.x = g.lines[lineCol.line].getLineEnd()
|
||||
return prior
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func (g *glyphIndex) atStartOfLine(pos combinedPos) bool {
|
||||
if pos.runes == 0 {
|
||||
return true
|
||||
}
|
||||
prevRuneIndex := pos.runes - 1
|
||||
lineOfPrevRune := g.positions[prevRuneIndex].lineCol.line
|
||||
return lineOfPrevRune < pos.lineCol.line
|
||||
}
|
||||
|
||||
func (g *glyphIndex) atEndOfLine(pos combinedPos) bool {
|
||||
if pos.runes == g.positions[len(g.positions)-1].runes {
|
||||
return true
|
||||
}
|
||||
next := pos.runes + 1
|
||||
hasNext := next < len(g.positions)
|
||||
return hasNext && g.positions[next].lineCol.line > pos.lineCol.line
|
||||
}
|
||||
|
||||
func dist(a, b fixed.Int26_6) fixed.Int26_6 {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return b - a
|
||||
}
|
||||
|
||||
func (g *glyphIndex) closestToXY(x fixed.Int26_6, y int) (pos combinedPos, atEndOfLine bool) {
|
||||
if len(g.positions) == 0 {
|
||||
return combinedPos{}, false
|
||||
}
|
||||
i := sort.Search(len(g.positions), func(i int) bool {
|
||||
pos := g.positions[i]
|
||||
return pos.y+pos.descent.Round() >= y
|
||||
})
|
||||
// If no position was greater than the provided Y, the text is too
|
||||
// short. Return either the last position or (if there are no
|
||||
// positions) the zero position.
|
||||
if i == len(g.positions) {
|
||||
return g.positions[i-1], false
|
||||
}
|
||||
first := g.positions[i]
|
||||
// Find the best X coordinate.
|
||||
closest := i
|
||||
closestDist := dist(first.x, x)
|
||||
line := first.lineCol.line
|
||||
// NOTE(whereswaldon): there isn't a simple way to accelerate this. Bidi text means that the x coordinates
|
||||
// for positions have no fixed relationship. In the future, we can consider sorting the positions
|
||||
// on a line by their x coordinate and caching that. It'll be a one-time O(nlogn) per line, but
|
||||
// subsequent uses of this function for that line become O(logn). Right now it's always O(n).
|
||||
for i := i + 1; i < len(g.positions) && g.positions[i].lineCol.line == line; i++ {
|
||||
candidate := g.positions[i]
|
||||
distance := dist(candidate.x, x)
|
||||
// If we are *really* close to the current position candidate, just choose it.
|
||||
if distance.Round() == 0 {
|
||||
return g.positions[i], false
|
||||
}
|
||||
if distance < closestDist {
|
||||
closestDist = distance
|
||||
closest = i
|
||||
}
|
||||
}
|
||||
next := closest + 1
|
||||
hasNext := next < len(g.positions)
|
||||
if hasNext && g.atEndOfLine(g.positions[closest]) {
|
||||
distance := dist(g.lines[line].getLineEnd(), x)
|
||||
if distance < closestDist {
|
||||
return g.positions[next], true
|
||||
}
|
||||
}
|
||||
return g.positions[closest], false
|
||||
}
|
||||
|
||||
// makeRegion creates a text-aligned rectangle from start to end. The vertical
|
||||
// dimensions of the rectangle are derived from the provided line's ascent and
|
||||
// descent, and the y offset of the line's baseline is provided as y.
|
||||
func makeRegion(line lineInfo, y int, start, end fixed.Int26_6) Region {
|
||||
if start > end {
|
||||
start, end = end, start
|
||||
}
|
||||
dotStart := image.Pt(start.Round(), y)
|
||||
dotEnd := image.Pt(end.Round(), y)
|
||||
return Region{
|
||||
Bounds: image.Rectangle{
|
||||
Min: dotStart.Sub(image.Point{Y: line.ascent.Ceil()}),
|
||||
Max: dotEnd.Add(image.Point{Y: line.descent.Floor()}),
|
||||
},
|
||||
Baseline: line.descent.Floor(),
|
||||
}
|
||||
}
|
||||
|
||||
// Region describes the position and baseline of an area of interest within
|
||||
// shaped text.
|
||||
type Region struct {
|
||||
// Bounds is the coordinates of the bounding box relative to the containing
|
||||
// widget.
|
||||
Bounds image.Rectangle
|
||||
// Baseline is the quantity of vertical pixels between the baseline and
|
||||
// the bottom of bounds.
|
||||
Baseline int
|
||||
}
|
||||
|
||||
// locate returns highlight regions covering the glyphs that represent the runes in
|
||||
// [startRune,endRune). If the rects parameter is non-nil, locate will use it to
|
||||
// return results instead of allocating, provided that there is enough capacity.
|
||||
// The returned regions have their Bounds specified relative to the provided
|
||||
// viewport.
|
||||
func (g *glyphIndex) locate(viewport image.Rectangle, startRune, endRune int, rects []Region) []Region {
|
||||
if startRune > endRune {
|
||||
startRune, endRune = endRune, startRune
|
||||
}
|
||||
rects = rects[:0]
|
||||
caretStart, _ := g.closestToRune(startRune)
|
||||
caretEnd, _ := g.closestToRune(endRune)
|
||||
|
||||
for lineIdx := caretStart.lineCol.line; lineIdx < len(g.lines); lineIdx++ {
|
||||
if lineIdx > caretEnd.lineCol.line {
|
||||
break
|
||||
}
|
||||
pos := g.closestToLineCol(screenPos{line: lineIdx})
|
||||
if int(pos.y)+pos.descent.Ceil() < viewport.Min.Y {
|
||||
continue
|
||||
}
|
||||
if int(pos.y)-pos.ascent.Ceil() > viewport.Max.Y {
|
||||
break
|
||||
}
|
||||
line := g.lines[lineIdx]
|
||||
if lineIdx > caretStart.lineCol.line && lineIdx < caretEnd.lineCol.line {
|
||||
startX := line.xOff
|
||||
endX := startX + line.width
|
||||
// The entire line is selected.
|
||||
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
||||
continue
|
||||
}
|
||||
selectionStart := caretStart
|
||||
selectionEnd := caretEnd
|
||||
if lineIdx != caretStart.lineCol.line {
|
||||
// This line does not contain the beginning of the selection.
|
||||
selectionStart = g.closestToLineCol(screenPos{line: lineIdx})
|
||||
}
|
||||
if lineIdx != caretEnd.lineCol.line {
|
||||
// This line does not contain the end of the selection.
|
||||
selectionEnd = g.closestToLineCol(screenPos{line: lineIdx, col: math.MaxInt})
|
||||
}
|
||||
|
||||
var (
|
||||
startX, endX fixed.Int26_6
|
||||
eof bool
|
||||
)
|
||||
lineLoop:
|
||||
for !eof {
|
||||
startX = selectionStart.x
|
||||
if selectionStart.runIndex == selectionEnd.runIndex {
|
||||
// Commit selection.
|
||||
endX = selectionEnd.x
|
||||
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
||||
break
|
||||
} else {
|
||||
currentDirection := selectionStart.towardOrigin
|
||||
previous := selectionStart
|
||||
runLoop:
|
||||
for !eof {
|
||||
// Increment the start position until the next logical run.
|
||||
for startRun := selectionStart.runIndex; selectionStart.runIndex == startRun; {
|
||||
previous = selectionStart
|
||||
selectionStart, eof = g.incrementPosition(selectionStart)
|
||||
if eof {
|
||||
endX = selectionStart.x
|
||||
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
||||
break runLoop
|
||||
}
|
||||
}
|
||||
if selectionStart.towardOrigin != currentDirection {
|
||||
endX = previous.x
|
||||
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
||||
break
|
||||
}
|
||||
if selectionStart.runIndex == selectionEnd.runIndex {
|
||||
// Commit selection.
|
||||
endX = selectionEnd.x
|
||||
rects = append(rects, makeRegion(line, pos.y, startX, endX))
|
||||
break lineLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range rects {
|
||||
rects[i].Bounds = rects[i].Bounds.Sub(viewport.Min)
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
// graphemeReader segments paragraphs of text into grapheme clusters.
|
||||
type graphemeReader struct {
|
||||
segmenter.Segmenter
|
||||
graphemes []int
|
||||
paragraph []rune
|
||||
source io.ReaderAt
|
||||
cursor int64
|
||||
reader *bufio.Reader
|
||||
runeOffset int
|
||||
}
|
||||
|
||||
// SetSource configures the reader to pull from source.
|
||||
func (p *graphemeReader) SetSource(source io.ReaderAt) {
|
||||
p.source = source
|
||||
p.cursor = 0
|
||||
p.reader = bufio.NewReader(p)
|
||||
p.runeOffset = 0
|
||||
}
|
||||
|
||||
// Read exists to satisfy io.Reader. It should not be directly invoked.
|
||||
func (p *graphemeReader) Read(b []byte) (int, error) {
|
||||
n, err := p.source.ReadAt(b, p.cursor)
|
||||
p.cursor += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// next decodes one paragraph of rune data.
|
||||
func (p *graphemeReader) next() ([]rune, bool) {
|
||||
p.paragraph = p.paragraph[:0]
|
||||
var err error
|
||||
var r rune
|
||||
for err == nil {
|
||||
r, _, err = p.reader.ReadRune()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
p.paragraph = append(p.paragraph, r)
|
||||
if r == '\n' {
|
||||
break
|
||||
}
|
||||
}
|
||||
return p.paragraph, err == nil
|
||||
}
|
||||
|
||||
// Graphemes will return the next paragraph's grapheme cluster boundaries,
|
||||
// if any. If it returns an empty slice, there is no more data (all paragraphs
|
||||
// have been segmented).
|
||||
func (p *graphemeReader) Graphemes() []int {
|
||||
var more bool
|
||||
p.graphemes = p.graphemes[:0]
|
||||
p.paragraph, more = p.next()
|
||||
if len(p.paragraph) == 0 && !more {
|
||||
return nil
|
||||
}
|
||||
p.Segmenter.Init(p.paragraph)
|
||||
iter := p.Segmenter.GraphemeIterator()
|
||||
if iter.Next() {
|
||||
graph := iter.Grapheme()
|
||||
p.graphemes = append(p.graphemes,
|
||||
p.runeOffset+graph.Offset,
|
||||
p.runeOffset+graph.Offset+len(graph.Text),
|
||||
)
|
||||
}
|
||||
for iter.Next() {
|
||||
graph := iter.Grapheme()
|
||||
p.graphemes = append(p.graphemes, p.runeOffset+graph.Offset+len(graph.Text))
|
||||
}
|
||||
p.runeOffset += len(p.paragraph)
|
||||
return p.graphemes
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/font"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
// Label is a widget for laying out and drawing text. Labels are always
|
||||
// non-interactive text. They cannot be selected or copied.
|
||||
type Label struct {
|
||||
// Alignment specifies the text alignment.
|
||||
Alignment text.Alignment
|
||||
// MaxLines limits the number of lines. Zero means no limit.
|
||||
MaxLines int
|
||||
// Truncator is the text that will be shown at the end of the final
|
||||
// line if MaxLines is exceeded. Defaults to "…" if empty.
|
||||
Truncator string
|
||||
// WrapPolicy configures how displayed text will be broken into lines.
|
||||
WrapPolicy text.WrapPolicy
|
||||
// LineHeight controls the distance between the baselines of lines of text.
|
||||
// If zero, a sensible default will be used.
|
||||
LineHeight unit.Sp
|
||||
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
||||
// sensible default will be used.
|
||||
LineHeightScale float32
|
||||
}
|
||||
|
||||
// Layout the label with the given shaper, font, size, text, and material.
|
||||
func (l Label) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt string, textMaterial op.CallOp) layout.Dimensions {
|
||||
dims, _ := l.LayoutDetailed(gtx, lt, font, size, txt, textMaterial)
|
||||
return dims
|
||||
}
|
||||
|
||||
// TextInfo provides metadata about shaped text.
|
||||
type TextInfo struct {
|
||||
// Truncated contains the number of runes of text that are represented by a truncator
|
||||
// symbol in the text. If zero, there is no truncator symbol.
|
||||
Truncated int
|
||||
}
|
||||
|
||||
// Layout the label with the given shaper, font, size, text, and material, returning metadata about the shaped text.
|
||||
func (l Label) LayoutDetailed(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, txt string, textMaterial op.CallOp) (layout.Dimensions, TextInfo) {
|
||||
cs := gtx.Constraints
|
||||
textSize := fixed.I(gtx.Sp(size))
|
||||
lineHeight := fixed.I(gtx.Sp(l.LineHeight))
|
||||
lt.LayoutString(text.Parameters{
|
||||
Font: font,
|
||||
PxPerEm: textSize,
|
||||
MaxLines: l.MaxLines,
|
||||
Truncator: l.Truncator,
|
||||
Alignment: l.Alignment,
|
||||
WrapPolicy: l.WrapPolicy,
|
||||
MaxWidth: cs.Max.X,
|
||||
MinWidth: cs.Min.X,
|
||||
Locale: gtx.Locale,
|
||||
LineHeight: lineHeight,
|
||||
LineHeightScale: l.LineHeightScale,
|
||||
}, txt)
|
||||
m := op.Record(gtx.Ops)
|
||||
viewport := image.Rectangle{Max: cs.Max}
|
||||
it := textIterator{
|
||||
viewport: viewport,
|
||||
maxLines: l.MaxLines,
|
||||
material: textMaterial,
|
||||
}
|
||||
semantic.LabelOp(txt).Add(gtx.Ops)
|
||||
var glyphs [32]text.Glyph
|
||||
line := glyphs[:0]
|
||||
for g, ok := lt.NextGlyph(); ok; g, ok = lt.NextGlyph() {
|
||||
var ok bool
|
||||
if line, ok = it.paintGlyph(gtx, lt, g, line); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
call := m.Stop()
|
||||
viewport.Min = viewport.Min.Add(it.padding.Min)
|
||||
viewport.Max = viewport.Max.Add(it.padding.Max)
|
||||
clipStack := clip.Rect(viewport).Push(gtx.Ops)
|
||||
call.Add(gtx.Ops)
|
||||
dims := layout.Dimensions{Size: it.bounds.Size()}
|
||||
dims.Size = cs.Constrain(dims.Size)
|
||||
dims.Baseline = dims.Size.Y - it.baseline
|
||||
clipStack.Pop()
|
||||
return dims, TextInfo{Truncated: it.truncated}
|
||||
}
|
||||
|
||||
// textIterator computes the bounding box of and paints text.
|
||||
type textIterator struct {
|
||||
// viewport is the rectangle of document coordinates that the iterator is
|
||||
// trying to fill with text.
|
||||
viewport image.Rectangle
|
||||
// maxLines is the maximum number of text lines that should be displayed.
|
||||
maxLines int
|
||||
// material sets the paint material for the text glyphs. If none is provided
|
||||
// the color of the glyphs is undefined and may change unpredictably if the
|
||||
// text contains color glyphs.
|
||||
material op.CallOp
|
||||
// truncated tracks the count of truncated runes in the text.
|
||||
truncated int
|
||||
// linesSeen tracks the quantity of line endings this iterator has seen.
|
||||
linesSeen int
|
||||
// lineOff tracks the origin for the glyphs in the current line.
|
||||
lineOff f32.Point
|
||||
// padding is the space needed outside of the bounds of the text to ensure no
|
||||
// part of a glyph is clipped.
|
||||
padding image.Rectangle
|
||||
// bounds is the logical bounding box of the text.
|
||||
bounds image.Rectangle
|
||||
// visible tracks whether the most recently iterated glyph is visible within
|
||||
// the viewport.
|
||||
visible bool
|
||||
// first tracks whether the iterator has processed a glyph yet.
|
||||
first bool
|
||||
// baseline tracks the location of the first line of text's baseline.
|
||||
baseline int
|
||||
}
|
||||
|
||||
// processGlyph checks whether the glyph is visible within the iterator's configured
|
||||
// viewport and (if so) updates the iterator's text dimensions to include the glyph.
|
||||
func (it *textIterator) processGlyph(g text.Glyph, ok bool) (visibleOrBefore bool) {
|
||||
if it.maxLines > 0 {
|
||||
if g.Flags&text.FlagTruncator != 0 && g.Flags&text.FlagClusterBreak != 0 {
|
||||
// A glyph carrying both of these flags provides the count of truncated runes.
|
||||
it.truncated = int(g.Runes)
|
||||
}
|
||||
if g.Flags&text.FlagLineBreak != 0 {
|
||||
it.linesSeen++
|
||||
}
|
||||
if it.linesSeen == it.maxLines && g.Flags&text.FlagParagraphBreak != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Compute the maximum extent to which glyphs overhang on the horizontal
|
||||
// axis.
|
||||
if d := g.Bounds.Min.X.Floor(); d < it.padding.Min.X {
|
||||
// If the distance between the dot and the left edge of this glyph is
|
||||
// less than the current padding, increase the left padding.
|
||||
it.padding.Min.X = d
|
||||
}
|
||||
if d := (g.Bounds.Max.X - g.Advance).Ceil(); d > it.padding.Max.X {
|
||||
// If the distance between the dot and the right edge of this glyph
|
||||
// minus the logical advance of this glyph is greater than the current
|
||||
// padding, increase the right padding.
|
||||
it.padding.Max.X = d
|
||||
}
|
||||
if d := (g.Bounds.Min.Y + g.Ascent).Floor(); d < it.padding.Min.Y {
|
||||
// If the distance between the dot and the top of this glyph is greater
|
||||
// than the ascent of the glyph, increase the top padding.
|
||||
it.padding.Min.Y = d
|
||||
}
|
||||
if d := (g.Bounds.Max.Y - g.Descent).Ceil(); d > it.padding.Max.Y {
|
||||
// If the distance between the dot and the bottom of this glyph is greater
|
||||
// than the descent of the glyph, increase the bottom padding.
|
||||
it.padding.Max.Y = d
|
||||
}
|
||||
logicalBounds := image.Rectangle{
|
||||
Min: image.Pt(g.X.Floor(), int(g.Y)-g.Ascent.Ceil()),
|
||||
Max: image.Pt((g.X + g.Advance).Ceil(), int(g.Y)+g.Descent.Ceil()),
|
||||
}
|
||||
if !it.first {
|
||||
it.first = true
|
||||
it.baseline = int(g.Y)
|
||||
it.bounds = logicalBounds
|
||||
}
|
||||
|
||||
above := logicalBounds.Max.Y < it.viewport.Min.Y
|
||||
below := logicalBounds.Min.Y > it.viewport.Max.Y
|
||||
left := logicalBounds.Max.X < it.viewport.Min.X
|
||||
right := logicalBounds.Min.X > it.viewport.Max.X
|
||||
it.visible = !above && !below && !left && !right
|
||||
if it.visible {
|
||||
it.bounds.Min.X = min(it.bounds.Min.X, logicalBounds.Min.X)
|
||||
it.bounds.Min.Y = min(it.bounds.Min.Y, logicalBounds.Min.Y)
|
||||
it.bounds.Max.X = max(it.bounds.Max.X, logicalBounds.Max.X)
|
||||
it.bounds.Max.Y = max(it.bounds.Max.Y, logicalBounds.Max.Y)
|
||||
}
|
||||
return ok && !below
|
||||
}
|
||||
|
||||
func fixedToFloat(i fixed.Int26_6) float32 {
|
||||
return float32(i) / 64.0
|
||||
}
|
||||
|
||||
// paintGlyph buffers up and paints text glyphs. It should be invoked iteratively upon each glyph
|
||||
// until it returns false. The line parameter should be a slice with
|
||||
// a backing array of sufficient size to buffer multiple glyphs.
|
||||
// A modified slice will be returned with each invocation, and is
|
||||
// expected to be passed back in on the following invocation.
|
||||
// This design is awkward, but prevents the line slice from escaping
|
||||
// to the heap.
|
||||
func (it *textIterator) paintGlyph(gtx layout.Context, shaper *text.Shaper, glyph text.Glyph, line []text.Glyph) ([]text.Glyph, bool) {
|
||||
visibleOrBefore := it.processGlyph(glyph, true)
|
||||
if it.visible {
|
||||
if len(line) == 0 {
|
||||
it.lineOff = f32.Point{X: fixedToFloat(glyph.X), Y: float32(glyph.Y)}.Sub(layout.FPt(it.viewport.Min))
|
||||
}
|
||||
line = append(line, glyph)
|
||||
}
|
||||
if glyph.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 || !visibleOrBefore {
|
||||
t := op.Affine(f32.AffineId().Offset(it.lineOff)).Push(gtx.Ops)
|
||||
path := shaper.Shape(line)
|
||||
outline := clip.Outline{Path: path}.Op().Push(gtx.Ops)
|
||||
it.material.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
outline.Pop()
|
||||
if call := shaper.Bitmaps(line); call != (op.CallOp{}) {
|
||||
call.Add(gtx.Ops)
|
||||
}
|
||||
t.Pop()
|
||||
line = line[:0]
|
||||
}
|
||||
return line, visibleOrBefore
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
)
|
||||
|
||||
// Scrollbar holds the persistent state for an area that can
|
||||
// display a scrollbar. In particular, it tracks the position of a
|
||||
// viewport along a one-dimensional region of content. The viewport's
|
||||
// position can be adjusted by drag operations along the display area,
|
||||
// or by clicks within the display area.
|
||||
//
|
||||
// Scrollbar additionally detects when a scroll indicator region is
|
||||
// hovered.
|
||||
type Scrollbar struct {
|
||||
track, indicator gesture.Click
|
||||
drag gesture.Drag
|
||||
delta float32
|
||||
|
||||
dragging bool
|
||||
oldDragPos float32
|
||||
}
|
||||
|
||||
// Update updates the internal state of the scrollbar based on events
|
||||
// since the previous call to Update. The provided axis will be used to
|
||||
// normalize input event coordinates and constraints into an axis-
|
||||
// independent format. viewportStart is the position of the beginning
|
||||
// of the scrollable viewport relative to the underlying content expressed
|
||||
// as a value in the range [0,1]. viewportEnd is the position of the end
|
||||
// of the viewport relative to the underlying content, also expressed
|
||||
// as a value in the range [0,1]. For example, if viewportStart is 0.25
|
||||
// and viewportEnd is .5, the viewport described by the scrollbar is
|
||||
// currently showing the second quarter of the underlying content.
|
||||
func (s *Scrollbar) Update(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) {
|
||||
// Calculate the length of the major axis of the scrollbar. This is
|
||||
// the length of the track within which pointer events occur, and is
|
||||
// used to scale those interactions.
|
||||
trackHeight := float32(axis.Convert(gtx.Constraints.Max).X)
|
||||
s.delta = 0
|
||||
|
||||
centerOnClick := func(normalizedPos float32) {
|
||||
// When the user clicks on the scrollbar we center on that point, respecting the limits of the beginning and end
|
||||
// of the scrollbar.
|
||||
//
|
||||
// Centering gives a consistent experience whether the user clicks above or below the indicator.
|
||||
target := normalizedPos - (viewportEnd-viewportStart)/2
|
||||
s.delta += target - viewportStart
|
||||
if s.delta < -viewportStart {
|
||||
s.delta = -viewportStart
|
||||
} else if s.delta > 1-viewportEnd {
|
||||
s.delta = 1 - viewportEnd
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to a click in the track.
|
||||
for {
|
||||
event, ok := s.track.Update(gtx.Source)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if event.Kind != gesture.KindClick ||
|
||||
event.Modifiers != key.Modifiers(0) ||
|
||||
event.NumClicks > 1 {
|
||||
continue
|
||||
}
|
||||
pos := axis.Convert(image.Point{
|
||||
X: int(event.Position.X),
|
||||
Y: int(event.Position.Y),
|
||||
})
|
||||
normalizedPos := float32(pos.X) / trackHeight
|
||||
// Clicking on the indicator should not jump to that position on the track. The user might've just intended to
|
||||
// drag and changed their mind.
|
||||
if !(normalizedPos >= viewportStart && normalizedPos <= viewportEnd) {
|
||||
centerOnClick(normalizedPos)
|
||||
}
|
||||
}
|
||||
|
||||
// Offset to account for any drags.
|
||||
for {
|
||||
event, ok := s.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(axis))
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch event.Kind {
|
||||
case pointer.Drag:
|
||||
case pointer.Release, pointer.Cancel:
|
||||
s.dragging = false
|
||||
continue
|
||||
default:
|
||||
continue
|
||||
}
|
||||
dragOffset := axis.FConvert(event.Position).X
|
||||
// The user can drag outside of the constraints, or even the window. Limit dragging to within the scrollbar.
|
||||
if dragOffset < 0 {
|
||||
dragOffset = 0
|
||||
} else if dragOffset > trackHeight {
|
||||
dragOffset = trackHeight
|
||||
}
|
||||
normalizedDragOffset := dragOffset / trackHeight
|
||||
|
||||
if !s.dragging {
|
||||
s.dragging = true
|
||||
s.oldDragPos = normalizedDragOffset
|
||||
|
||||
if normalizedDragOffset < viewportStart || normalizedDragOffset > viewportEnd {
|
||||
// The user started dragging somewhere on the track that isn't covered by the indicator. Consider this a
|
||||
// click in addition to a drag and jump to the clicked point.
|
||||
//
|
||||
// TODO(dh): this isn't perfect. We only get the pointer.Drag event once the user has actually dragged,
|
||||
// which means that if the user presses the mouse button and neither releases it nor drags it, nothing
|
||||
// will happen.
|
||||
pos := axis.Convert(image.Point{
|
||||
X: int(event.Position.X),
|
||||
Y: int(event.Position.Y),
|
||||
})
|
||||
normalizedPos := float32(pos.X) / trackHeight
|
||||
centerOnClick(normalizedPos)
|
||||
}
|
||||
} else {
|
||||
s.delta += normalizedDragOffset - s.oldDragPos
|
||||
|
||||
if viewportStart+s.delta < 0 {
|
||||
// Adjust normalizedDragOffset - and thus the future s.oldDragPos - so that futile dragging up has to be
|
||||
// countered with dragging down again. Otherwise, dragging up would have no effect, but dragging down would
|
||||
// immediately start scrolling. We want the user to undo their ineffective drag first.
|
||||
normalizedDragOffset -= viewportStart + s.delta
|
||||
// Limit s.delta to the maximum amount scrollable
|
||||
s.delta = -viewportStart
|
||||
} else if viewportEnd+s.delta > 1 {
|
||||
normalizedDragOffset += (1 - viewportEnd) - s.delta
|
||||
s.delta = 1 - viewportEnd
|
||||
}
|
||||
s.oldDragPos = normalizedDragOffset
|
||||
}
|
||||
}
|
||||
|
||||
// Process events from the indicator so that hover is
|
||||
// detected properly.
|
||||
for {
|
||||
if _, ok := s.indicator.Update(gtx.Source); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddTrack configures the track click listener for the scrollbar to use
|
||||
// the current clip area.
|
||||
func (s *Scrollbar) AddTrack(ops *op.Ops) {
|
||||
s.track.Add(ops)
|
||||
}
|
||||
|
||||
// AddIndicator configures the indicator click listener for the scrollbar to use
|
||||
// the current clip area.
|
||||
func (s *Scrollbar) AddIndicator(ops *op.Ops) {
|
||||
s.indicator.Add(ops)
|
||||
}
|
||||
|
||||
// AddDrag configures the drag listener for the scrollbar to use
|
||||
// the current clip area.
|
||||
func (s *Scrollbar) AddDrag(ops *op.Ops) {
|
||||
s.drag.Add(ops)
|
||||
}
|
||||
|
||||
// IndicatorHovered reports whether the scroll indicator is currently being
|
||||
// hovered by the pointer.
|
||||
func (s *Scrollbar) IndicatorHovered() bool {
|
||||
return s.indicator.Hovered()
|
||||
}
|
||||
|
||||
// TrackHovered reports whether the scroll track is being hovered by the
|
||||
// pointer.
|
||||
func (s *Scrollbar) TrackHovered() bool {
|
||||
return s.track.Hovered()
|
||||
}
|
||||
|
||||
// ScrollDistance returns the normalized distance that the scrollbar
|
||||
// moved during the last call to Layout as a value in the range [-1,1].
|
||||
func (s *Scrollbar) ScrollDistance() float32 {
|
||||
return s.delta
|
||||
}
|
||||
|
||||
// Dragging reports whether the user is currently performing a drag gesture
|
||||
// on the indicator. Note that this can return false while ScrollDistance is nonzero
|
||||
// if the user scrolls using a different control than the scrollbar (like a mouse
|
||||
// wheel).
|
||||
func (s *Scrollbar) Dragging() bool {
|
||||
return s.dragging
|
||||
}
|
||||
|
||||
// List holds the persistent state for a layout.List that has a
|
||||
// scrollbar attached.
|
||||
type List struct {
|
||||
Scrollbar
|
||||
layout.List
|
||||
}
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
type ButtonStyle struct {
|
||||
Text string
|
||||
// Color is the text color.
|
||||
Color color.NRGBA
|
||||
Font font.Font
|
||||
TextSize unit.Sp
|
||||
Background color.NRGBA
|
||||
CornerRadius unit.Dp
|
||||
Inset layout.Inset
|
||||
Button *widget.Clickable
|
||||
shaper *text.Shaper
|
||||
}
|
||||
|
||||
type ButtonLayoutStyle struct {
|
||||
Background color.NRGBA
|
||||
CornerRadius unit.Dp
|
||||
Button *widget.Clickable
|
||||
}
|
||||
|
||||
type IconButtonStyle struct {
|
||||
Background color.NRGBA
|
||||
// Color is the icon color.
|
||||
Color color.NRGBA
|
||||
Icon *widget.Icon
|
||||
// Size is the icon size.
|
||||
Size unit.Dp
|
||||
Inset layout.Inset
|
||||
Button *widget.Clickable
|
||||
Description string
|
||||
}
|
||||
|
||||
func Button(th *Theme, button *widget.Clickable, txt string) ButtonStyle {
|
||||
b := ButtonStyle{
|
||||
Text: txt,
|
||||
Color: th.Palette.ContrastFg,
|
||||
CornerRadius: 4,
|
||||
Background: th.Palette.ContrastBg,
|
||||
TextSize: th.TextSize * 14.0 / 16.0,
|
||||
Inset: layout.Inset{
|
||||
Top: 10, Bottom: 10,
|
||||
Left: 12, Right: 12,
|
||||
},
|
||||
Button: button,
|
||||
shaper: th.Shaper,
|
||||
}
|
||||
b.Font.Typeface = th.Face
|
||||
return b
|
||||
}
|
||||
|
||||
func ButtonLayout(th *Theme, button *widget.Clickable) ButtonLayoutStyle {
|
||||
return ButtonLayoutStyle{
|
||||
Button: button,
|
||||
Background: th.Palette.ContrastBg,
|
||||
CornerRadius: 4,
|
||||
}
|
||||
}
|
||||
|
||||
func IconButton(th *Theme, button *widget.Clickable, icon *widget.Icon, description string) IconButtonStyle {
|
||||
return IconButtonStyle{
|
||||
Background: th.Palette.ContrastBg,
|
||||
Color: th.Palette.ContrastFg,
|
||||
Icon: icon,
|
||||
Size: 24,
|
||||
Inset: layout.UniformInset(12),
|
||||
Button: button,
|
||||
Description: description,
|
||||
}
|
||||
}
|
||||
|
||||
// Clickable lays out a rectangular clickable widget without further
|
||||
// decoration.
|
||||
func Clickable(gtx layout.Context, button *widget.Clickable, w layout.Widget) layout.Dimensions {
|
||||
return button.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
defer clip.Rect{Max: gtx.Constraints.Min}.Push(gtx.Ops).Pop()
|
||||
if button.Hovered() || gtx.Focused(button) {
|
||||
paint.Fill(gtx.Ops, f32color.Hovered(color.NRGBA{}))
|
||||
}
|
||||
for _, c := range button.History() {
|
||||
drawInk(gtx, c)
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
w,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (b ButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
return ButtonLayoutStyle{
|
||||
Background: b.Background,
|
||||
CornerRadius: b.CornerRadius,
|
||||
Button: b.Button,
|
||||
}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
colMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: b.Color}.Add(gtx.Ops)
|
||||
return widget.Label{Alignment: text.Middle}.Layout(gtx, b.shaper, b.Font, b.TextSize, b.Text, colMacro.Stop())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (b ButtonLayoutStyle) Layout(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||
min := gtx.Constraints.Min
|
||||
return b.Button.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rr := gtx.Dp(b.CornerRadius)
|
||||
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
|
||||
background := b.Background
|
||||
switch {
|
||||
case !gtx.Enabled():
|
||||
background = f32color.Disabled(b.Background)
|
||||
case b.Button.Hovered() || gtx.Focused(b.Button):
|
||||
background = f32color.Hovered(b.Background)
|
||||
}
|
||||
paint.Fill(gtx.Ops, background)
|
||||
for _, c := range b.Button.History() {
|
||||
drawInk(gtx, c)
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min = min
|
||||
return layout.Center.Layout(gtx, w)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (b IconButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
m := op.Record(gtx.Ops)
|
||||
dims := b.Button.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.Button.Add(gtx.Ops)
|
||||
if d := b.Description; d != "" {
|
||||
semantic.DescriptionOp(b.Description).Add(gtx.Ops)
|
||||
}
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
rr := (gtx.Constraints.Min.X + gtx.Constraints.Min.Y) / 4
|
||||
defer clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops).Pop()
|
||||
background := b.Background
|
||||
switch {
|
||||
case !gtx.Enabled():
|
||||
background = f32color.Disabled(b.Background)
|
||||
case b.Button.Hovered() || gtx.Focused(b.Button):
|
||||
background = f32color.Hovered(b.Background)
|
||||
}
|
||||
paint.Fill(gtx.Ops, background)
|
||||
for _, c := range b.Button.History() {
|
||||
drawInk(gtx, c)
|
||||
}
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return b.Inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(b.Size)
|
||||
if b.Icon != nil {
|
||||
gtx.Constraints.Min = image.Point{X: size}
|
||||
b.Icon.Layout(gtx, b.Color)
|
||||
}
|
||||
return layout.Dimensions{
|
||||
Size: image.Point{X: size, Y: size},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
c := m.Stop()
|
||||
bounds := image.Rectangle{Max: dims.Size}
|
||||
defer clip.Ellipse(bounds).Push(gtx.Ops).Pop()
|
||||
c.Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
|
||||
func drawInk(gtx layout.Context, c widget.Press) {
|
||||
// duration is the number of seconds for the
|
||||
// completed animation: expand while fading in, then
|
||||
// out.
|
||||
const (
|
||||
expandDuration = float32(0.5)
|
||||
fadeDuration = float32(0.9)
|
||||
)
|
||||
|
||||
now := gtx.Now
|
||||
|
||||
t := float32(now.Sub(c.Start).Seconds())
|
||||
|
||||
end := c.End
|
||||
if end.IsZero() {
|
||||
// If the press hasn't ended, don't fade-out.
|
||||
end = now
|
||||
}
|
||||
|
||||
endt := float32(end.Sub(c.Start).Seconds())
|
||||
|
||||
// Compute the fade-in/out position in [0;1].
|
||||
var alphat float32
|
||||
{
|
||||
var haste float32
|
||||
if c.Cancelled {
|
||||
// If the press was cancelled before the inkwell
|
||||
// was fully faded in, fast forward the animation
|
||||
// to match the fade-out.
|
||||
if h := 0.5 - endt/fadeDuration; h > 0 {
|
||||
haste = h
|
||||
}
|
||||
}
|
||||
// Fade in.
|
||||
half1 := t/fadeDuration + haste
|
||||
if half1 > 0.5 {
|
||||
half1 = 0.5
|
||||
}
|
||||
|
||||
// Fade out.
|
||||
half2 := float32(now.Sub(end).Seconds())
|
||||
half2 /= fadeDuration
|
||||
half2 += haste
|
||||
if half2 > 0.5 {
|
||||
// Too old.
|
||||
return
|
||||
}
|
||||
|
||||
alphat = half1 + half2
|
||||
}
|
||||
|
||||
// Compute the expand position in [0;1].
|
||||
sizet := t
|
||||
if c.Cancelled {
|
||||
// Freeze expansion of cancelled presses.
|
||||
sizet = endt
|
||||
}
|
||||
sizet /= expandDuration
|
||||
|
||||
// Animate only ended presses, and presses that are fading in.
|
||||
if !c.End.IsZero() || sizet <= 1.0 {
|
||||
gtx.Execute(op.InvalidateCmd{})
|
||||
}
|
||||
|
||||
if sizet > 1.0 {
|
||||
sizet = 1.0
|
||||
}
|
||||
|
||||
if alphat > .5 {
|
||||
// Start fadeout after half the animation.
|
||||
alphat = 1.0 - alphat
|
||||
}
|
||||
// Twice the speed to attain fully faded in at 0.5.
|
||||
t2 := alphat * 2
|
||||
// Beziér ease-in curve.
|
||||
alphaBezier := t2 * t2 * (3.0 - 2.0*t2)
|
||||
sizeBezier := sizet * sizet * (3.0 - 2.0*sizet)
|
||||
size := gtx.Constraints.Min.X
|
||||
if h := gtx.Constraints.Min.Y; h > size {
|
||||
size = h
|
||||
}
|
||||
// Cover the entire constraints min rectangle and
|
||||
// apply curve values to size and color.
|
||||
size = int(float32(size) * 2 * float32(math.Sqrt(2)) * sizeBezier)
|
||||
alpha := 0.7 * alphaBezier
|
||||
const col = 0.8
|
||||
ba, bc := byte(alpha*0xff), byte(col*0xff)
|
||||
rgba := f32color.MulAlpha(color.NRGBA{A: 0xff, R: bc, G: bc, B: bc}, ba)
|
||||
ink := paint.ColorOp{Color: rgba}
|
||||
ink.Add(gtx.Ops)
|
||||
rr := size / 2
|
||||
defer op.Offset(c.Position.Add(image.Point{
|
||||
X: -rr,
|
||||
Y: -rr,
|
||||
})).Push(gtx.Ops).Pop()
|
||||
defer clip.UniformRRect(image.Rectangle{Max: image.Pt(size, size)}, rr).Push(gtx.Ops).Pop()
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
type checkable struct {
|
||||
Label string
|
||||
Color color.NRGBA
|
||||
Font font.Font
|
||||
TextSize unit.Sp
|
||||
IconColor color.NRGBA
|
||||
Size unit.Dp
|
||||
shaper *text.Shaper
|
||||
checkedStateIcon *widget.Icon
|
||||
uncheckedStateIcon *widget.Icon
|
||||
}
|
||||
|
||||
func (c *checkable) layout(gtx layout.Context, checked, hovered bool) layout.Dimensions {
|
||||
var icon *widget.Icon
|
||||
if checked {
|
||||
icon = c.checkedStateIcon
|
||||
} else {
|
||||
icon = c.uncheckedStateIcon
|
||||
}
|
||||
|
||||
dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.Stack{Alignment: layout.Center}.Layout(gtx,
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(c.Size) * 4 / 3
|
||||
dims := layout.Dimensions{
|
||||
Size: image.Point{X: size, Y: size},
|
||||
}
|
||||
if !hovered {
|
||||
return dims
|
||||
}
|
||||
|
||||
background := f32color.MulAlpha(c.IconColor, 70)
|
||||
|
||||
b := image.Rectangle{Max: image.Pt(size, size)}
|
||||
paint.FillShape(gtx.Ops, background, clip.Ellipse(b).Op(gtx.Ops))
|
||||
|
||||
return dims
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(2).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(c.Size)
|
||||
col := c.IconColor
|
||||
if !gtx.Enabled() {
|
||||
col = f32color.Disabled(col)
|
||||
}
|
||||
gtx.Constraints.Min = image.Point{X: size}
|
||||
icon.Layout(gtx, col)
|
||||
return layout.Dimensions{
|
||||
Size: image.Point{X: size, Y: size},
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
return layout.UniformInset(2).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
colMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: c.Color}.Add(gtx.Ops)
|
||||
return widget.Label{}.Layout(gtx, c.shaper, c.Font, c.TextSize, c.Label, colMacro.Stop())
|
||||
})
|
||||
}),
|
||||
)
|
||||
return dims
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
type CheckBoxStyle struct {
|
||||
checkable
|
||||
CheckBox *widget.Bool
|
||||
}
|
||||
|
||||
func CheckBox(th *Theme, checkBox *widget.Bool, label string) CheckBoxStyle {
|
||||
c := CheckBoxStyle{
|
||||
CheckBox: checkBox,
|
||||
checkable: checkable{
|
||||
Label: label,
|
||||
Color: th.Palette.Fg,
|
||||
IconColor: th.Palette.ContrastBg,
|
||||
TextSize: th.TextSize * 14.0 / 16.0,
|
||||
Size: 26,
|
||||
shaper: th.Shaper,
|
||||
checkedStateIcon: th.Icon.CheckBoxChecked,
|
||||
uncheckedStateIcon: th.Icon.CheckBoxUnchecked,
|
||||
},
|
||||
}
|
||||
c.checkable.Font.Typeface = th.Face
|
||||
return c
|
||||
}
|
||||
|
||||
// Layout updates the checkBox and displays it.
|
||||
func (c CheckBoxStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
return c.CheckBox.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.CheckBox.Add(gtx.Ops)
|
||||
return c.layout(gtx, c.CheckBox.Value, c.CheckBox.Hovered() || gtx.Focused(c.CheckBox))
|
||||
})
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
// DecorationsStyle provides the style elements for Decorations.
|
||||
type DecorationsStyle struct {
|
||||
Decorations *widget.Decorations
|
||||
Actions system.Action
|
||||
Title LabelStyle
|
||||
Background color.NRGBA
|
||||
Foreground color.NRGBA
|
||||
}
|
||||
|
||||
// Decorations returns the style to decorate a window.
|
||||
func Decorations(th *Theme, deco *widget.Decorations, actions system.Action, title string) DecorationsStyle {
|
||||
titleStyle := Body1(th, title)
|
||||
titleStyle.Color = th.Palette.ContrastFg
|
||||
return DecorationsStyle{
|
||||
Decorations: deco,
|
||||
Actions: actions,
|
||||
Title: titleStyle,
|
||||
Background: th.Palette.ContrastBg,
|
||||
Foreground: th.Palette.ContrastFg,
|
||||
}
|
||||
}
|
||||
|
||||
// Layout a window with its title and action buttons.
|
||||
func (d DecorationsStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
rec := op.Record(gtx.Ops)
|
||||
dims := d.layoutDecorations(gtx)
|
||||
decos := rec.Stop()
|
||||
r := clip.Rect{Max: dims.Size}
|
||||
paint.FillShape(gtx.Ops, d.Background, r.Op())
|
||||
decos.Add(gtx.Ops)
|
||||
return dims
|
||||
}
|
||||
|
||||
func (d DecorationsStyle) layoutDecorations(gtx layout.Context) layout.Dimensions {
|
||||
gtx.Constraints.Min.Y = 0
|
||||
inset := layout.UniformInset(10)
|
||||
return layout.Flex{
|
||||
Axis: layout.Horizontal,
|
||||
Alignment: layout.Middle,
|
||||
Spacing: layout.SpaceBetween,
|
||||
}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
return d.Decorations.LayoutMove(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, d.Title.Layout)
|
||||
})
|
||||
}),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
// Remove the unmaximize action as it is taken care of by maximize.
|
||||
actions := d.Actions &^ system.ActionUnmaximize
|
||||
var size image.Point
|
||||
for a := system.Action(1); actions != 0; a <<= 1 {
|
||||
if a&actions == 0 {
|
||||
continue
|
||||
}
|
||||
actions &^= a
|
||||
var w layout.Widget
|
||||
switch a {
|
||||
case system.ActionMinimize:
|
||||
w = minimizeWindow
|
||||
case system.ActionMaximize:
|
||||
if d.Decorations.Maximized {
|
||||
w = maximizedWindow
|
||||
} else {
|
||||
w = maximizeWindow
|
||||
}
|
||||
case system.ActionClose:
|
||||
w = closeWindow
|
||||
default:
|
||||
continue
|
||||
}
|
||||
cl := d.Decorations.Clickable(a)
|
||||
dims := Clickable(gtx, cl, func(gtx layout.Context) layout.Dimensions {
|
||||
system.ActionInputOp(a).Add(gtx.Ops)
|
||||
paint.ColorOp{Color: d.Foreground}.Add(gtx.Ops)
|
||||
return inset.Layout(gtx, w)
|
||||
})
|
||||
size.X += dims.Size.X
|
||||
if size.Y < dims.Size.Y {
|
||||
size.Y = dims.Size.Y
|
||||
}
|
||||
op.Offset(image.Pt(dims.Size.X, 0)).Add(gtx.Ops)
|
||||
}
|
||||
return layout.Dimensions{Size: size}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const (
|
||||
winIconSize = unit.Dp(20)
|
||||
winIconMargin = unit.Dp(4)
|
||||
winIconStroke = unit.Dp(2)
|
||||
)
|
||||
|
||||
// minimizeWindows draws a line icon representing the minimize action.
|
||||
func minimizeWindow(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(winIconSize)
|
||||
size32 := float32(size)
|
||||
margin := float32(gtx.Dp(winIconMargin))
|
||||
width := float32(gtx.Dp(winIconStroke))
|
||||
var p clip.Path
|
||||
p.Begin(gtx.Ops)
|
||||
p.MoveTo(f32.Point{X: margin, Y: size32 - margin})
|
||||
p.LineTo(f32.Point{X: size32 - 2*margin, Y: size32 - margin})
|
||||
st := clip.Stroke{
|
||||
Path: p.End(),
|
||||
Width: width,
|
||||
}.Op().Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
return layout.Dimensions{Size: image.Pt(size, size)}
|
||||
}
|
||||
|
||||
// maximizeWindow draws a rectangle representing the maximize action.
|
||||
func maximizeWindow(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(winIconSize)
|
||||
margin := gtx.Dp(winIconMargin)
|
||||
width := gtx.Dp(winIconStroke)
|
||||
r := clip.RRect{
|
||||
Rect: image.Rect(margin, margin, size-margin, size-margin),
|
||||
}
|
||||
st := clip.Stroke{
|
||||
Path: r.Path(gtx.Ops),
|
||||
Width: float32(width),
|
||||
}.Op().Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
r.Rect.Max = image.Pt(size-margin, 2*margin)
|
||||
st = clip.Outline{
|
||||
Path: r.Path(gtx.Ops),
|
||||
}.Op().Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
return layout.Dimensions{Size: image.Pt(size, size)}
|
||||
}
|
||||
|
||||
// maximizedWindow draws interleaved rectangles representing the un-maximize action.
|
||||
func maximizedWindow(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(winIconSize)
|
||||
margin := gtx.Dp(winIconMargin)
|
||||
width := gtx.Dp(winIconStroke)
|
||||
r := clip.RRect{
|
||||
Rect: image.Rect(margin, margin, size-2*margin, size-2*margin),
|
||||
}
|
||||
st := clip.Stroke{
|
||||
Path: r.Path(gtx.Ops),
|
||||
Width: float32(width),
|
||||
}.Op().Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
r = clip.RRect{
|
||||
Rect: image.Rect(2*margin, 2*margin, size-margin, size-margin),
|
||||
}
|
||||
st = clip.Stroke{
|
||||
Path: r.Path(gtx.Ops),
|
||||
Width: float32(width),
|
||||
}.Op().Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
return layout.Dimensions{Size: image.Pt(size, size)}
|
||||
}
|
||||
|
||||
// closeWindow draws a cross representing the close action.
|
||||
func closeWindow(gtx layout.Context) layout.Dimensions {
|
||||
size := gtx.Dp(winIconSize)
|
||||
size32 := float32(size)
|
||||
margin := float32(gtx.Dp(winIconMargin))
|
||||
width := float32(gtx.Dp(winIconStroke))
|
||||
var p clip.Path
|
||||
p.Begin(gtx.Ops)
|
||||
p.MoveTo(f32.Point{X: margin, Y: margin})
|
||||
p.LineTo(f32.Point{X: size32 - margin, Y: size32 - margin})
|
||||
p.MoveTo(f32.Point{X: size32 - margin, Y: margin})
|
||||
p.LineTo(f32.Point{X: margin, Y: size32 - margin})
|
||||
st := clip.Stroke{
|
||||
Path: p.End(),
|
||||
Width: width,
|
||||
}.Op().Push(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
return layout.Dimensions{Size: image.Pt(size, size)}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
// Package material implements the Material design.
|
||||
//
|
||||
// To maximize reusability and visual flexibility, user interface controls are
|
||||
// split into two parts: the stateful widget and the stateless drawing of it.
|
||||
//
|
||||
// For example, widget.Clickable encapsulates the state and event
|
||||
// handling of all clickable areas, while the Theme is responsible to
|
||||
// draw a specific area, for example a button.
|
||||
//
|
||||
// This snippet defines a button that prints a message when clicked:
|
||||
//
|
||||
// var gtx layout.Context
|
||||
// button := new(widget.Clickable)
|
||||
//
|
||||
// for button.Clicked(gtx) {
|
||||
// fmt.Println("Clicked!")
|
||||
// }
|
||||
//
|
||||
// Use a Theme to draw the button:
|
||||
//
|
||||
// theme := material.NewTheme(...)
|
||||
//
|
||||
// material.Button(theme, button, "Click me!").Layout(gtx)
|
||||
//
|
||||
// # Customization
|
||||
//
|
||||
// Quite often, a program needs to customize the theme-provided defaults. Several
|
||||
// options are available, depending on the nature of the change.
|
||||
//
|
||||
// Mandatory parameters: Some parameters are not part of the widget state but
|
||||
// have no obvious default. In the program above, the button text is a
|
||||
// parameter to the Theme.Button method.
|
||||
//
|
||||
// Theme-global parameters: For changing the look of all widgets drawn with a
|
||||
// particular theme, adjust the `Theme` fields:
|
||||
//
|
||||
// theme.Palette.Fg = color.NRGBA{...}
|
||||
//
|
||||
// Widget-local parameters: For changing the look of a particular widget,
|
||||
// adjust the widget specific theme object:
|
||||
//
|
||||
// btn := material.Button(theme, button, "Click me!")
|
||||
// btn.Font.Style = text.Italic
|
||||
// btn.Layout(gtx)
|
||||
//
|
||||
// Widget variants: A widget can have several distinct representations even
|
||||
// though the underlying state is the same. A widget.Clickable can be drawn as a
|
||||
// round icon button:
|
||||
//
|
||||
// icon := widget.NewIcon(...)
|
||||
//
|
||||
// material.IconButton(theme, button, icon, "Click me!").Layout(gtx)
|
||||
//
|
||||
// Specialized widgets: Theme both define a generic Label method
|
||||
// that takes a text size, and specialized methods for standard text
|
||||
// sizes such as Theme.H1 and Theme.Body2.
|
||||
package material
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
type EditorStyle struct {
|
||||
Font font.Font
|
||||
// LineHeight controls the distance between the baselines of lines of text.
|
||||
// If zero, a sensible default will be used.
|
||||
LineHeight unit.Sp
|
||||
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
||||
// sensible default will be used.
|
||||
LineHeightScale float32
|
||||
TextSize unit.Sp
|
||||
// Color is the text color.
|
||||
Color color.NRGBA
|
||||
// Hint contains the text displayed when the editor is empty.
|
||||
Hint string
|
||||
// HintColor is the color of hint text.
|
||||
HintColor color.NRGBA
|
||||
// SelectionColor is the color of the background for selected text.
|
||||
SelectionColor color.NRGBA
|
||||
Editor *widget.Editor
|
||||
|
||||
shaper *text.Shaper
|
||||
}
|
||||
|
||||
func Editor(th *Theme, editor *widget.Editor, hint string) EditorStyle {
|
||||
return EditorStyle{
|
||||
Editor: editor,
|
||||
Font: font.Font{
|
||||
Typeface: th.Face,
|
||||
},
|
||||
TextSize: th.TextSize,
|
||||
Color: th.Palette.Fg,
|
||||
shaper: th.Shaper,
|
||||
Hint: hint,
|
||||
HintColor: f32color.MulAlpha(th.Palette.Fg, 0xbb),
|
||||
SelectionColor: f32color.MulAlpha(th.Palette.ContrastBg, 0x60),
|
||||
}
|
||||
}
|
||||
|
||||
func (e EditorStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
// Choose colors.
|
||||
textColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: e.Color}.Add(gtx.Ops)
|
||||
textColor := textColorMacro.Stop()
|
||||
hintColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: e.HintColor}.Add(gtx.Ops)
|
||||
hintColor := hintColorMacro.Stop()
|
||||
selectionColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: blendDisabledColor(!gtx.Enabled(), e.SelectionColor)}.Add(gtx.Ops)
|
||||
selectionColor := selectionColorMacro.Stop()
|
||||
|
||||
var maxlines int
|
||||
if e.Editor.SingleLine {
|
||||
maxlines = 1
|
||||
}
|
||||
|
||||
macro := op.Record(gtx.Ops)
|
||||
tl := widget.Label{
|
||||
Alignment: e.Editor.Alignment,
|
||||
MaxLines: maxlines,
|
||||
LineHeight: e.LineHeight,
|
||||
LineHeightScale: e.LineHeightScale,
|
||||
}
|
||||
dims := tl.Layout(gtx, e.shaper, e.Font, e.TextSize, e.Hint, hintColor)
|
||||
call := macro.Stop()
|
||||
|
||||
if w := dims.Size.X; gtx.Constraints.Min.X < w {
|
||||
gtx.Constraints.Min.X = w
|
||||
}
|
||||
if h := dims.Size.Y; gtx.Constraints.Min.Y < h {
|
||||
gtx.Constraints.Min.Y = h
|
||||
}
|
||||
e.Editor.LineHeight = e.LineHeight
|
||||
e.Editor.LineHeightScale = e.LineHeightScale
|
||||
dims = e.Editor.Layout(gtx, e.shaper, e.Font, e.TextSize, textColor, selectionColor)
|
||||
if e.Editor.Len() == 0 {
|
||||
call.Add(gtx.Ops)
|
||||
}
|
||||
return dims
|
||||
}
|
||||
|
||||
func blendDisabledColor(disabled bool, c color.NRGBA) color.NRGBA {
|
||||
if disabled {
|
||||
return f32color.Disabled(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
// LabelStyle configures the presentation of text. If the State field is set, the
|
||||
// label will be laid out as interactive (able to be selected and copied). Otherwise,
|
||||
// the label will be non-interactive.
|
||||
type LabelStyle struct {
|
||||
// Face defines the text style.
|
||||
Font font.Font
|
||||
// Color is the text color.
|
||||
Color color.NRGBA
|
||||
// SelectionColor is the color of the background for selected text.
|
||||
SelectionColor color.NRGBA
|
||||
// Alignment specify the text alignment.
|
||||
Alignment text.Alignment
|
||||
// MaxLines limits the number of lines. Zero means no limit.
|
||||
MaxLines int
|
||||
// WrapPolicy configures how displayed text will be broken into lines.
|
||||
WrapPolicy text.WrapPolicy
|
||||
// Truncator is the text that will be shown at the end of the final
|
||||
// line if MaxLines is exceeded. Defaults to "…" if empty.
|
||||
Truncator string
|
||||
// Text is the content displayed by the label.
|
||||
Text string
|
||||
// TextSize determines the size of the text glyphs.
|
||||
TextSize unit.Sp
|
||||
// LineHeight controls the distance between the baselines of lines of text.
|
||||
// If zero, a sensible default will be used.
|
||||
LineHeight unit.Sp
|
||||
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
||||
// sensible default will be used.
|
||||
LineHeightScale float32
|
||||
|
||||
// Shaper is the text shaper used to display this labe. This field is automatically
|
||||
// set using by all constructor functions. If constructing a LabelStyle literal, you
|
||||
// must provide a Shaper or displaying text will panic.
|
||||
Shaper *text.Shaper
|
||||
// State provides text selection state for the label. If not set, the label cannot
|
||||
// be selected or copied interactively.
|
||||
State *widget.Selectable
|
||||
}
|
||||
|
||||
func H1(th *Theme, txt string) LabelStyle {
|
||||
label := Label(th, th.TextSize*96.0/16.0, txt)
|
||||
label.Font.Weight = font.Light
|
||||
return label
|
||||
}
|
||||
|
||||
func H2(th *Theme, txt string) LabelStyle {
|
||||
label := Label(th, th.TextSize*60.0/16.0, txt)
|
||||
label.Font.Weight = font.Light
|
||||
return label
|
||||
}
|
||||
|
||||
func H3(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*48.0/16.0, txt)
|
||||
}
|
||||
|
||||
func H4(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*34.0/16.0, txt)
|
||||
}
|
||||
|
||||
func H5(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*24.0/16.0, txt)
|
||||
}
|
||||
|
||||
func H6(th *Theme, txt string) LabelStyle {
|
||||
label := Label(th, th.TextSize*20.0/16.0, txt)
|
||||
label.Font.Weight = font.Medium
|
||||
return label
|
||||
}
|
||||
|
||||
func Subtitle1(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*16.0/16.0, txt)
|
||||
}
|
||||
|
||||
func Subtitle2(th *Theme, txt string) LabelStyle {
|
||||
label := Label(th, th.TextSize*14.0/16.0, txt)
|
||||
label.Font.Weight = font.Medium
|
||||
return label
|
||||
}
|
||||
|
||||
func Body1(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize, txt)
|
||||
}
|
||||
|
||||
func Body2(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*14.0/16.0, txt)
|
||||
}
|
||||
|
||||
func Caption(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*12.0/16.0, txt)
|
||||
}
|
||||
|
||||
func Overline(th *Theme, txt string) LabelStyle {
|
||||
return Label(th, th.TextSize*10.0/16.0, txt)
|
||||
}
|
||||
|
||||
func Label(th *Theme, size unit.Sp, txt string) LabelStyle {
|
||||
l := LabelStyle{
|
||||
Text: txt,
|
||||
Color: th.Palette.Fg,
|
||||
SelectionColor: f32color.MulAlpha(th.Palette.ContrastBg, 0x60),
|
||||
TextSize: size,
|
||||
Shaper: th.Shaper,
|
||||
}
|
||||
l.Font.Typeface = th.Face
|
||||
return l
|
||||
}
|
||||
|
||||
func (l LabelStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
textColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: l.Color}.Add(gtx.Ops)
|
||||
textColor := textColorMacro.Stop()
|
||||
selectColorMacro := op.Record(gtx.Ops)
|
||||
paint.ColorOp{Color: l.SelectionColor}.Add(gtx.Ops)
|
||||
selectColor := selectColorMacro.Stop()
|
||||
|
||||
if l.State != nil {
|
||||
if l.State.Text() != l.Text {
|
||||
l.State.SetText(l.Text)
|
||||
}
|
||||
l.State.Alignment = l.Alignment
|
||||
l.State.MaxLines = l.MaxLines
|
||||
l.State.Truncator = l.Truncator
|
||||
l.State.WrapPolicy = l.WrapPolicy
|
||||
l.State.LineHeight = l.LineHeight
|
||||
l.State.LineHeightScale = l.LineHeightScale
|
||||
return l.State.Layout(gtx, l.Shaper, l.Font, l.TextSize, textColor, selectColor)
|
||||
}
|
||||
tl := widget.Label{
|
||||
Alignment: l.Alignment,
|
||||
MaxLines: l.MaxLines,
|
||||
Truncator: l.Truncator,
|
||||
WrapPolicy: l.WrapPolicy,
|
||||
LineHeight: l.LineHeight,
|
||||
LineHeightScale: l.LineHeightScale,
|
||||
}
|
||||
return tl.Layout(gtx, l.Shaper, l.Font, l.TextSize, l.Text, textColor)
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
// fromListPosition converts a layout.Position into two floats representing
|
||||
// the location of the viewport on the underlying content. It needs to know
|
||||
// the number of elements in the list and the major-axis size of the list
|
||||
// in order to do this. The returned values will be in the range [0,1], and
|
||||
// start will be less than or equal to end.
|
||||
func fromListPosition(lp layout.Position, elements int, majorAxisSize int) (start, end float32) {
|
||||
// Approximate the size of the scrollable content.
|
||||
lengthEstPx := float32(lp.Length)
|
||||
elementLenEstPx := lengthEstPx / float32(elements)
|
||||
|
||||
// Determine how much of the content is visible.
|
||||
listOffsetF := float32(lp.Offset)
|
||||
listOffsetL := float32(lp.OffsetLast)
|
||||
|
||||
// Compute the location of the beginning of the viewport using estimated element size and known
|
||||
// pixel offsets.
|
||||
viewportStart := clamp1((float32(lp.First)*elementLenEstPx + listOffsetF) / lengthEstPx)
|
||||
viewportEnd := clamp1((float32(lp.First+lp.Count)*elementLenEstPx + listOffsetL) / lengthEstPx)
|
||||
viewportFraction := viewportEnd - viewportStart
|
||||
|
||||
// Compute the expected visible proportion of the list content based solely on the ratio
|
||||
// of the visible size and the estimated total size.
|
||||
visiblePx := float32(majorAxisSize)
|
||||
visibleFraction := visiblePx / lengthEstPx
|
||||
|
||||
// Compute the error between the two methods of determining the viewport and diffuse the
|
||||
// error on either end of the viewport based on how close we are to each end.
|
||||
err := visibleFraction - viewportFraction
|
||||
adjStart := viewportStart
|
||||
adjEnd := viewportEnd
|
||||
if viewportFraction < 1 {
|
||||
startShare := viewportStart / (1 - viewportFraction)
|
||||
endShare := (1 - viewportEnd) / (1 - viewportFraction)
|
||||
startErr := startShare * err
|
||||
endErr := endShare * err
|
||||
|
||||
adjStart -= startErr
|
||||
adjEnd += endErr
|
||||
}
|
||||
return adjStart, adjEnd
|
||||
}
|
||||
|
||||
// rangeIsScrollable returns whether the viewport described by start and end
|
||||
// is smaller than the underlying content (such that it can be scrolled).
|
||||
// start and end are expected to each be in the range [0,1], and start
|
||||
// must be less than or equal to end.
|
||||
func rangeIsScrollable(start, end float32) bool {
|
||||
return end-start < 1
|
||||
}
|
||||
|
||||
// ScrollTrackStyle configures the presentation of a track for a scroll area.
|
||||
type ScrollTrackStyle struct {
|
||||
// MajorPadding and MinorPadding along the major and minor axis of the
|
||||
// scrollbar's track. This is used to keep the scrollbar from touching
|
||||
// the edges of the content area.
|
||||
MajorPadding, MinorPadding unit.Dp
|
||||
// Color of the track background.
|
||||
Color color.NRGBA
|
||||
}
|
||||
|
||||
// ScrollIndicatorStyle configures the presentation of a scroll indicator.
|
||||
type ScrollIndicatorStyle struct {
|
||||
// MajorMinLen is the smallest that the scroll indicator is allowed to
|
||||
// be along the major axis.
|
||||
MajorMinLen unit.Dp
|
||||
// MinorWidth is the width of the scroll indicator across the minor axis.
|
||||
MinorWidth unit.Dp
|
||||
// Color and HoverColor are the normal and hovered colors of the scroll
|
||||
// indicator.
|
||||
Color, HoverColor color.NRGBA
|
||||
// CornerRadius is the corner radius of the rectangular indicator. 0
|
||||
// will produce square corners. 0.5*MinorWidth will produce perfectly
|
||||
// round corners.
|
||||
CornerRadius unit.Dp
|
||||
}
|
||||
|
||||
// ScrollbarStyle configures the presentation of a scrollbar.
|
||||
type ScrollbarStyle struct {
|
||||
Scrollbar *widget.Scrollbar
|
||||
Track ScrollTrackStyle
|
||||
Indicator ScrollIndicatorStyle
|
||||
}
|
||||
|
||||
// Scrollbar configures the presentation of a scrollbar using the provided
|
||||
// theme and state.
|
||||
func Scrollbar(th *Theme, state *widget.Scrollbar) ScrollbarStyle {
|
||||
lightFg := th.Palette.Fg
|
||||
lightFg.A = 150
|
||||
darkFg := lightFg
|
||||
darkFg.A = 200
|
||||
|
||||
return ScrollbarStyle{
|
||||
Scrollbar: state,
|
||||
Track: ScrollTrackStyle{
|
||||
MajorPadding: 2,
|
||||
MinorPadding: 2,
|
||||
},
|
||||
Indicator: ScrollIndicatorStyle{
|
||||
MajorMinLen: th.FingerSize,
|
||||
MinorWidth: 6,
|
||||
CornerRadius: 3,
|
||||
Color: lightFg,
|
||||
HoverColor: darkFg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the minor axis width of the scrollbar in its current
|
||||
// configuration (taking padding for the scroll track into account).
|
||||
func (s ScrollbarStyle) Width() unit.Dp {
|
||||
return s.Indicator.MinorWidth + s.Track.MinorPadding + s.Track.MinorPadding
|
||||
}
|
||||
|
||||
// Layout the scrollbar.
|
||||
func (s ScrollbarStyle) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
|
||||
if !rangeIsScrollable(viewportStart, viewportEnd) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
|
||||
// Set minimum constraints in an axis-independent way, then convert to
|
||||
// the correct representation for the current axis.
|
||||
convert := axis.Convert
|
||||
maxMajorAxis := convert(gtx.Constraints.Max).X
|
||||
gtx.Constraints.Min.X = maxMajorAxis
|
||||
gtx.Constraints.Min.Y = gtx.Dp(s.Width())
|
||||
gtx.Constraints.Min = convert(gtx.Constraints.Min)
|
||||
gtx.Constraints.Max = gtx.Constraints.Min
|
||||
|
||||
s.Scrollbar.Update(gtx, axis, viewportStart, viewportEnd)
|
||||
|
||||
// Darken indicator if hovered.
|
||||
if s.Scrollbar.IndicatorHovered() {
|
||||
s.Indicator.Color = s.Indicator.HoverColor
|
||||
}
|
||||
|
||||
return s.layout(gtx, axis, viewportStart, viewportEnd)
|
||||
}
|
||||
|
||||
// layout the scroll track and indicator.
|
||||
func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
|
||||
inset := layout.Inset{
|
||||
Top: s.Track.MajorPadding,
|
||||
Bottom: s.Track.MajorPadding,
|
||||
Left: s.Track.MinorPadding,
|
||||
Right: s.Track.MinorPadding,
|
||||
}
|
||||
if axis == layout.Horizontal {
|
||||
inset.Top, inset.Bottom, inset.Left, inset.Right = inset.Left, inset.Right, inset.Top, inset.Bottom
|
||||
}
|
||||
|
||||
return layout.Background{}.Layout(gtx,
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
// Lay out the draggable track underneath the scroll indicator.
|
||||
area := image.Rectangle{
|
||||
Max: gtx.Constraints.Min,
|
||||
}
|
||||
pointerArea := clip.Rect(area)
|
||||
defer pointerArea.Push(gtx.Ops).Pop()
|
||||
s.Scrollbar.AddDrag(gtx.Ops)
|
||||
|
||||
// Stack a normal clickable area on top of the draggable area
|
||||
// to capture non-dragging clicks.
|
||||
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
||||
defer pointerArea.Push(gtx.Ops).Pop()
|
||||
s.Scrollbar.AddTrack(gtx.Ops)
|
||||
|
||||
paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op())
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
},
|
||||
func(gtx layout.Context) layout.Dimensions {
|
||||
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
// Use axis-independent constraints.
|
||||
gtx.Constraints.Min = axis.Convert(gtx.Constraints.Min)
|
||||
gtx.Constraints.Max = axis.Convert(gtx.Constraints.Max)
|
||||
|
||||
// Compute the pixel size and position of the scroll indicator within
|
||||
// the track.
|
||||
trackLen := gtx.Constraints.Min.X
|
||||
viewStart := int(math.Round(float64(viewportStart) * float64(trackLen)))
|
||||
viewEnd := int(math.Round(float64(viewportEnd) * float64(trackLen)))
|
||||
indicatorLen := max(viewEnd-viewStart, gtx.Dp(s.Indicator.MajorMinLen))
|
||||
if viewStart+indicatorLen > trackLen {
|
||||
viewStart = trackLen - indicatorLen
|
||||
}
|
||||
indicatorDims := axis.Convert(image.Point{
|
||||
X: indicatorLen,
|
||||
Y: gtx.Dp(s.Indicator.MinorWidth),
|
||||
})
|
||||
radius := gtx.Dp(s.Indicator.CornerRadius)
|
||||
|
||||
// Lay out the indicator.
|
||||
offset := axis.Convert(image.Pt(viewStart, 0))
|
||||
defer op.Offset(offset).Push(gtx.Ops).Pop()
|
||||
paint.FillShape(gtx.Ops, s.Indicator.Color, clip.RRect{
|
||||
Rect: image.Rectangle{
|
||||
Max: indicatorDims,
|
||||
},
|
||||
SW: radius,
|
||||
NW: radius,
|
||||
NE: radius,
|
||||
SE: radius,
|
||||
}.Op(gtx.Ops))
|
||||
|
||||
// Add the indicator pointer hit area.
|
||||
area := clip.Rect(image.Rectangle{Max: indicatorDims})
|
||||
defer pointer.PassOp{}.Push(gtx.Ops).Pop()
|
||||
defer area.Push(gtx.Ops).Pop()
|
||||
s.Scrollbar.AddIndicator(gtx.Ops)
|
||||
|
||||
return layout.Dimensions{Size: axis.Convert(gtx.Constraints.Min)}
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// AnchorStrategy defines a means of attaching a scrollbar to content.
|
||||
type AnchorStrategy uint8
|
||||
|
||||
const (
|
||||
// Occupy reserves space for the scrollbar, making the underlying
|
||||
// content region smaller on one axis.
|
||||
Occupy AnchorStrategy = iota
|
||||
// Overlay causes the scrollbar to float atop the content without
|
||||
// occupying any space. Content in the underlying area can be occluded
|
||||
// by the scrollbar.
|
||||
Overlay
|
||||
)
|
||||
|
||||
// ListStyle configures the presentation of a layout.List with a scrollbar.
|
||||
type ListStyle struct {
|
||||
state *widget.List
|
||||
ScrollbarStyle
|
||||
AnchorStrategy
|
||||
}
|
||||
|
||||
// List constructs a ListStyle using the provided theme and state.
|
||||
func List(th *Theme, state *widget.List) ListStyle {
|
||||
return ListStyle{
|
||||
state: state,
|
||||
ScrollbarStyle: Scrollbar(th, &state.Scrollbar),
|
||||
}
|
||||
}
|
||||
|
||||
// Layout the list and its scrollbar.
|
||||
func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement) layout.Dimensions {
|
||||
originalConstraints := gtx.Constraints
|
||||
|
||||
// Determine how much space the scrollbar occupies.
|
||||
barWidth := gtx.Dp(l.Width())
|
||||
|
||||
if l.AnchorStrategy == Occupy {
|
||||
|
||||
// Reserve space for the scrollbar using the gtx constraints.
|
||||
max := l.state.Axis.Convert(gtx.Constraints.Max)
|
||||
min := l.state.Axis.Convert(gtx.Constraints.Min)
|
||||
max.Y -= barWidth
|
||||
if max.Y < 0 {
|
||||
max.Y = 0
|
||||
}
|
||||
min.Y -= barWidth
|
||||
if min.Y < 0 {
|
||||
min.Y = 0
|
||||
}
|
||||
gtx.Constraints.Max = l.state.Axis.Convert(max)
|
||||
gtx.Constraints.Min = l.state.Axis.Convert(min)
|
||||
}
|
||||
|
||||
listDims := l.state.List.Layout(gtx, length, w)
|
||||
gtx.Constraints = originalConstraints
|
||||
|
||||
// Draw the scrollbar.
|
||||
anchoring := layout.E
|
||||
if l.state.Axis == layout.Horizontal {
|
||||
anchoring = layout.S
|
||||
}
|
||||
majorAxisSize := l.state.Axis.Convert(listDims.Size).X
|
||||
start, end := fromListPosition(l.state.Position, length, majorAxisSize)
|
||||
// layout.Direction respects the minimum, so ensure that the
|
||||
// scrollbar will be drawn on the correct edge even if the provided
|
||||
// layout.Context had a zero minimum constraint.
|
||||
gtx.Constraints.Min = listDims.Size
|
||||
if l.AnchorStrategy == Occupy {
|
||||
min := l.state.Axis.Convert(gtx.Constraints.Min)
|
||||
min.Y += barWidth
|
||||
gtx.Constraints.Min = l.state.Axis.Convert(min)
|
||||
}
|
||||
anchoring.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
return l.ScrollbarStyle.Layout(gtx, l.state.Axis, start, end)
|
||||
})
|
||||
|
||||
if delta := l.state.ScrollDistance(); delta != 0 {
|
||||
// Handle any changes to the list position as a result of user interaction
|
||||
// with the scrollbar.
|
||||
l.state.List.ScrollBy(delta * float32(length))
|
||||
}
|
||||
|
||||
if l.AnchorStrategy == Occupy {
|
||||
// Increase the width to account for the space occupied by the scrollbar.
|
||||
cross := l.state.Axis.Convert(listDims.Size)
|
||||
cross.Y += barWidth
|
||||
listDims.Size = l.state.Axis.Convert(cross)
|
||||
}
|
||||
|
||||
return listDims
|
||||
}
|
||||
|
||||
// LayoutWidgets the widgets and its scrollbar.
|
||||
func (l ListStyle) LayoutWidgets(gtx layout.Context, widgets ...layout.Widget) layout.Dimensions {
|
||||
return l.Layout(gtx, len(widgets), func(gtx layout.Context, index int) layout.Dimensions {
|
||||
return widgets[index](gtx)
|
||||
})
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
)
|
||||
|
||||
type LoaderStyle struct {
|
||||
Color color.NRGBA
|
||||
}
|
||||
|
||||
func Loader(th *Theme) LoaderStyle {
|
||||
return LoaderStyle{
|
||||
Color: th.Palette.ContrastBg,
|
||||
}
|
||||
}
|
||||
|
||||
func (l LoaderStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
diam := gtx.Constraints.Min.X
|
||||
if minY := gtx.Constraints.Min.Y; minY > diam {
|
||||
diam = minY
|
||||
}
|
||||
if diam == 0 {
|
||||
diam = gtx.Dp(24)
|
||||
}
|
||||
sz := gtx.Constraints.Constrain(image.Pt(diam, diam))
|
||||
radius := sz.X / 2
|
||||
defer op.Offset(image.Pt(radius, radius)).Push(gtx.Ops).Pop()
|
||||
|
||||
dt := float32((time.Duration(gtx.Now.UnixNano()) % (time.Second)).Seconds())
|
||||
startAngle := dt * math.Pi * 2
|
||||
endAngle := startAngle + math.Pi*1.5
|
||||
|
||||
defer clipLoader(gtx.Ops, startAngle, endAngle, float32(radius)).Push(gtx.Ops).Pop()
|
||||
paint.ColorOp{
|
||||
Color: l.Color,
|
||||
}.Add(gtx.Ops)
|
||||
defer op.Offset(image.Pt(-radius, -radius)).Push(gtx.Ops).Pop()
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
gtx.Execute(op.InvalidateCmd{})
|
||||
return layout.Dimensions{
|
||||
Size: sz,
|
||||
}
|
||||
}
|
||||
|
||||
func clipLoader(ops *op.Ops, startAngle, endAngle, radius float32) clip.Op {
|
||||
const thickness = .25
|
||||
|
||||
var (
|
||||
width = radius * thickness
|
||||
delta = endAngle - startAngle
|
||||
|
||||
vy, vx = math.Sincos(float64(startAngle))
|
||||
|
||||
inner = radius * (1. - thickness*.5)
|
||||
pen = f32.Pt(float32(vx), float32(vy)).Mul(inner)
|
||||
center = f32.Pt(0, 0).Sub(pen)
|
||||
|
||||
p clip.Path
|
||||
)
|
||||
|
||||
p.Begin(ops)
|
||||
p.Move(pen)
|
||||
p.Arc(center, center, delta)
|
||||
return clip.Stroke{
|
||||
Path: p.End(),
|
||||
Width: width,
|
||||
}.Op()
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
type ProgressBarStyle struct {
|
||||
Color color.NRGBA
|
||||
Height unit.Dp
|
||||
Radius unit.Dp
|
||||
TrackColor color.NRGBA
|
||||
Progress float32
|
||||
}
|
||||
|
||||
func ProgressBar(th *Theme, progress float32) ProgressBarStyle {
|
||||
return ProgressBarStyle{
|
||||
Progress: progress,
|
||||
Height: unit.Dp(4),
|
||||
Radius: unit.Dp(2),
|
||||
Color: th.Palette.ContrastBg,
|
||||
TrackColor: f32color.MulAlpha(th.Palette.Fg, 0x88),
|
||||
}
|
||||
}
|
||||
|
||||
func (p ProgressBarStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
shader := func(width int, color color.NRGBA) layout.Dimensions {
|
||||
d := image.Point{X: width, Y: gtx.Dp(p.Height)}
|
||||
rr := gtx.Dp(p.Radius)
|
||||
|
||||
defer clip.UniformRRect(image.Rectangle{Max: image.Pt(width, d.Y)}, rr).Push(gtx.Ops).Pop()
|
||||
paint.ColorOp{Color: color}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
|
||||
return layout.Dimensions{Size: d}
|
||||
}
|
||||
|
||||
progressBarWidth := gtx.Constraints.Max.X
|
||||
return layout.Stack{Alignment: layout.W}.Layout(gtx,
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
return shader(progressBarWidth, p.TrackColor)
|
||||
}),
|
||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||
fillWidth := int(float32(progressBarWidth) * clamp1(p.Progress))
|
||||
fillColor := p.Color
|
||||
if !gtx.Enabled() {
|
||||
fillColor = f32color.Disabled(fillColor)
|
||||
}
|
||||
if fillWidth < int(p.Radius*2) {
|
||||
fillWidth = int(p.Radius * 2)
|
||||
}
|
||||
return shader(fillWidth, fillColor)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// clamp1 limits v to range [0..1].
|
||||
func clamp1(v float32) float32 {
|
||||
if v >= 1 {
|
||||
return 1
|
||||
} else if v <= 0 {
|
||||
return 0
|
||||
} else {
|
||||
return v
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
)
|
||||
|
||||
type ProgressCircleStyle struct {
|
||||
Color color.NRGBA
|
||||
Progress float32
|
||||
}
|
||||
|
||||
func ProgressCircle(th *Theme, progress float32) ProgressCircleStyle {
|
||||
return ProgressCircleStyle{
|
||||
Color: th.Palette.ContrastBg,
|
||||
Progress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
func (p ProgressCircleStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
diam := gtx.Constraints.Min.X
|
||||
if minY := gtx.Constraints.Min.Y; minY > diam {
|
||||
diam = minY
|
||||
}
|
||||
if diam == 0 {
|
||||
diam = gtx.Dp(24)
|
||||
}
|
||||
sz := gtx.Constraints.Constrain(image.Pt(diam, diam))
|
||||
radius := sz.X / 2
|
||||
defer op.Offset(image.Pt(radius, radius)).Push(gtx.Ops).Pop()
|
||||
|
||||
defer clipLoader(gtx.Ops, -math.Pi/2, -math.Pi/2+math.Pi*2*p.Progress, float32(radius)).Push(gtx.Ops).Pop()
|
||||
paint.ColorOp{
|
||||
Color: p.Color,
|
||||
}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
return layout.Dimensions{
|
||||
Size: sz,
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
type RadioButtonStyle struct {
|
||||
checkable
|
||||
Key string
|
||||
Group *widget.Enum
|
||||
}
|
||||
|
||||
// RadioButton returns a RadioButton with a label. The key specifies
|
||||
// the value for the Enum.
|
||||
func RadioButton(th *Theme, group *widget.Enum, key, label string) RadioButtonStyle {
|
||||
r := RadioButtonStyle{
|
||||
Group: group,
|
||||
checkable: checkable{
|
||||
Label: label,
|
||||
|
||||
Color: th.Palette.Fg,
|
||||
IconColor: th.Palette.ContrastBg,
|
||||
TextSize: th.TextSize * 14.0 / 16.0,
|
||||
Size: 26,
|
||||
shaper: th.Shaper,
|
||||
checkedStateIcon: th.Icon.RadioChecked,
|
||||
uncheckedStateIcon: th.Icon.RadioUnchecked,
|
||||
},
|
||||
Key: key,
|
||||
}
|
||||
r.checkable.Font.Typeface = th.Face
|
||||
return r
|
||||
}
|
||||
|
||||
// Layout updates enum and displays the radio button.
|
||||
func (r RadioButtonStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
r.Group.Update(gtx)
|
||||
hovered, hovering := r.Group.Hovered()
|
||||
focus, focused := r.Group.Focused()
|
||||
return r.Group.Layout(gtx, r.Key, func(gtx layout.Context) layout.Dimensions {
|
||||
semantic.RadioButton.Add(gtx.Ops)
|
||||
highlight := hovering && hovered == r.Key || focused && focus == r.Key
|
||||
return r.layout(gtx, r.Group.Value == r.Key, highlight)
|
||||
})
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
// Slider is for selecting a value in a range.
|
||||
func Slider(th *Theme, float *widget.Float) SliderStyle {
|
||||
return SliderStyle{
|
||||
Color: th.Palette.ContrastBg,
|
||||
Float: float,
|
||||
FingerSize: th.FingerSize,
|
||||
}
|
||||
}
|
||||
|
||||
type SliderStyle struct {
|
||||
Axis layout.Axis
|
||||
Color color.NRGBA
|
||||
Float *widget.Float
|
||||
|
||||
FingerSize unit.Dp
|
||||
}
|
||||
|
||||
func (s SliderStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
const thumbRadius unit.Dp = 6
|
||||
tr := gtx.Dp(thumbRadius)
|
||||
trackWidth := gtx.Dp(2)
|
||||
|
||||
axis := s.Axis
|
||||
// Keep a minimum length so that the track is always visible.
|
||||
minLength := tr + 3*tr + tr
|
||||
// Try to expand to finger size, but only if the constraints
|
||||
// allow for it.
|
||||
touchSizePx := min(gtx.Dp(s.FingerSize), axis.Convert(gtx.Constraints.Max).Y)
|
||||
sizeMain := max(axis.Convert(gtx.Constraints.Min).X, minLength)
|
||||
sizeCross := max(2*tr, touchSizePx)
|
||||
size := axis.Convert(image.Pt(sizeMain, sizeCross))
|
||||
|
||||
o := axis.Convert(image.Pt(tr, 0))
|
||||
trans := op.Offset(o).Push(gtx.Ops)
|
||||
gtx.Constraints.Min = axis.Convert(image.Pt(sizeMain-2*tr, sizeCross))
|
||||
dims := s.Float.Layout(gtx, axis, thumbRadius)
|
||||
gtx.Constraints.Min = gtx.Constraints.Min.Add(axis.Convert(image.Pt(0, sizeCross)))
|
||||
thumbPos := tr + int(s.Float.Value*float32(axis.Convert(dims.Size).X))
|
||||
trans.Pop()
|
||||
|
||||
color := s.Color
|
||||
if !gtx.Enabled() {
|
||||
color = f32color.Disabled(color)
|
||||
}
|
||||
|
||||
rect := func(minx, miny, maxx, maxy int) image.Rectangle {
|
||||
r := image.Rect(minx, miny, maxx, maxy)
|
||||
if axis == layout.Vertical {
|
||||
r.Max.X, r.Min.X = sizeMain-r.Min.X, sizeMain-r.Max.X
|
||||
}
|
||||
r.Min = axis.Convert(r.Min)
|
||||
r.Max = axis.Convert(r.Max)
|
||||
return r
|
||||
}
|
||||
|
||||
// Draw track before thumb.
|
||||
track := rect(
|
||||
tr, sizeCross/2-trackWidth/2,
|
||||
thumbPos, sizeCross/2+trackWidth/2,
|
||||
)
|
||||
paint.FillShape(gtx.Ops, color, clip.Rect(track).Op())
|
||||
|
||||
// Draw track after thumb.
|
||||
track = rect(
|
||||
thumbPos, axis.Convert(track.Min).Y,
|
||||
sizeMain-tr, axis.Convert(track.Max).Y,
|
||||
)
|
||||
paint.FillShape(gtx.Ops, f32color.MulAlpha(color, 96), clip.Rect(track).Op())
|
||||
|
||||
// Draw thumb.
|
||||
pt := image.Pt(thumbPos, sizeCross/2)
|
||||
thumb := rect(
|
||||
pt.X-tr, pt.Y-tr,
|
||||
pt.X+tr, pt.Y+tr,
|
||||
)
|
||||
paint.FillShape(gtx.Ops, color, clip.Ellipse(thumb).Op(gtx.Ops))
|
||||
|
||||
return layout.Dimensions{Size: size}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/internal/f32color"
|
||||
"gioui.org/io/semantic"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
type SwitchStyle struct {
|
||||
Description string
|
||||
Color struct {
|
||||
Enabled color.NRGBA
|
||||
Disabled color.NRGBA
|
||||
Track color.NRGBA
|
||||
}
|
||||
Switch *widget.Bool
|
||||
}
|
||||
|
||||
// Switch is for selecting a boolean value.
|
||||
func Switch(th *Theme, swtch *widget.Bool, description string) SwitchStyle {
|
||||
sw := SwitchStyle{
|
||||
Switch: swtch,
|
||||
Description: description,
|
||||
}
|
||||
sw.Color.Enabled = th.Palette.ContrastBg
|
||||
sw.Color.Disabled = th.Palette.Bg
|
||||
sw.Color.Track = f32color.MulAlpha(th.Palette.Fg, 0x88)
|
||||
return sw
|
||||
}
|
||||
|
||||
// Layout updates the switch and displays it.
|
||||
func (s SwitchStyle) Layout(gtx layout.Context) layout.Dimensions {
|
||||
s.Switch.Update(gtx)
|
||||
trackWidth := gtx.Dp(36)
|
||||
trackHeight := gtx.Dp(16)
|
||||
thumbSize := gtx.Dp(20)
|
||||
trackOff := (thumbSize - trackHeight) / 2
|
||||
|
||||
// Draw track.
|
||||
trackCorner := trackHeight / 2
|
||||
trackRect := image.Rectangle{Max: image.Point{
|
||||
X: trackWidth,
|
||||
Y: trackHeight,
|
||||
}}
|
||||
col := s.Color.Disabled
|
||||
if s.Switch.Value {
|
||||
col = s.Color.Enabled
|
||||
}
|
||||
if !gtx.Enabled() {
|
||||
col = f32color.Disabled(col)
|
||||
}
|
||||
trackColor := s.Color.Track
|
||||
t := op.Offset(image.Point{Y: trackOff}).Push(gtx.Ops)
|
||||
cl := clip.UniformRRect(trackRect, trackCorner).Push(gtx.Ops)
|
||||
paint.ColorOp{Color: trackColor}.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
cl.Pop()
|
||||
t.Pop()
|
||||
|
||||
// Draw thumb ink.
|
||||
inkSize := gtx.Dp(44)
|
||||
rr := inkSize / 2
|
||||
inkOff := image.Point{
|
||||
X: trackWidth/2 - rr,
|
||||
Y: -rr + trackHeight/2 + trackOff,
|
||||
}
|
||||
t = op.Offset(inkOff).Push(gtx.Ops)
|
||||
gtx.Constraints.Min = image.Pt(inkSize, inkSize)
|
||||
cl = clip.UniformRRect(image.Rectangle{Max: gtx.Constraints.Min}, rr).Push(gtx.Ops)
|
||||
for _, p := range s.Switch.History() {
|
||||
drawInk(gtx, p)
|
||||
}
|
||||
cl.Pop()
|
||||
t.Pop()
|
||||
|
||||
// Compute thumb offset.
|
||||
if s.Switch.Value {
|
||||
xoff := trackWidth - thumbSize
|
||||
defer op.Offset(image.Point{X: xoff}).Push(gtx.Ops).Pop()
|
||||
}
|
||||
|
||||
thumbRadius := thumbSize / 2
|
||||
|
||||
circle := func(x, y, r int) clip.Op {
|
||||
b := image.Rectangle{
|
||||
Min: image.Pt(x-r, y-r),
|
||||
Max: image.Pt(x+r, y+r),
|
||||
}
|
||||
return clip.Ellipse(b).Op(gtx.Ops)
|
||||
}
|
||||
// Draw hover.
|
||||
if s.Switch.Hovered() || gtx.Focused(s.Switch) {
|
||||
r := thumbRadius * 10 / 17
|
||||
background := f32color.MulAlpha(s.Color.Enabled, 70)
|
||||
paint.FillShape(gtx.Ops, background, circle(thumbRadius, thumbRadius, r))
|
||||
}
|
||||
|
||||
// Draw thumb shadow, a translucent disc slightly larger than the
|
||||
// thumb itself.
|
||||
// Center shadow horizontally and slightly adjust its Y.
|
||||
paint.FillShape(gtx.Ops, argb(0x55000000), circle(thumbRadius, thumbRadius+gtx.Dp(.25), thumbRadius+1))
|
||||
|
||||
// Draw thumb.
|
||||
paint.FillShape(gtx.Ops, col, circle(thumbRadius, thumbRadius, thumbRadius))
|
||||
|
||||
// Set up click area.
|
||||
clickSize := gtx.Dp(40)
|
||||
clickOff := image.Point{
|
||||
X: (thumbSize - clickSize) / 2,
|
||||
Y: (trackHeight-clickSize)/2 + trackOff,
|
||||
}
|
||||
defer op.Offset(clickOff).Push(gtx.Ops).Pop()
|
||||
sz := image.Pt(clickSize, clickSize)
|
||||
defer clip.Ellipse(image.Rectangle{Max: sz}).Push(gtx.Ops).Pop()
|
||||
s.Switch.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||
if d := s.Description; d != "" {
|
||||
semantic.DescriptionOp(d).Add(gtx.Ops)
|
||||
}
|
||||
semantic.Switch.Add(gtx.Ops)
|
||||
return layout.Dimensions{Size: sz}
|
||||
})
|
||||
|
||||
dims := image.Point{X: trackWidth, Y: thumbSize}
|
||||
return layout.Dimensions{Size: dims}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: Unlicense OR MIT
|
||||
|
||||
package material
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"golang.org/x/exp/shiny/materialdesign/icons"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
// Palette contains the minimal set of colors that a widget may need to
|
||||
// draw itself.
|
||||
type Palette struct {
|
||||
// Bg is the background color atop which content is currently being
|
||||
// drawn.
|
||||
Bg color.NRGBA
|
||||
|
||||
// Fg is a color suitable for drawing on top of Bg.
|
||||
Fg color.NRGBA
|
||||
|
||||
// ContrastBg is a color used to draw attention to active,
|
||||
// important, interactive widgets such as buttons.
|
||||
ContrastBg color.NRGBA
|
||||
|
||||
// ContrastFg is a color suitable for content drawn on top of
|
||||
// ContrastBg.
|
||||
ContrastFg color.NRGBA
|
||||
}
|
||||
|
||||
// Theme holds the general theme of an app or window. Different top-level
|
||||
// windows should have different instances of Theme (with different Shapers;
|
||||
// see the godoc for [text.Shaper]), though their other fields can be equal.
|
||||
type Theme struct {
|
||||
Shaper *text.Shaper
|
||||
Palette
|
||||
TextSize unit.Sp
|
||||
Icon struct {
|
||||
CheckBoxChecked *widget.Icon
|
||||
CheckBoxUnchecked *widget.Icon
|
||||
RadioChecked *widget.Icon
|
||||
RadioUnchecked *widget.Icon
|
||||
}
|
||||
// Face selects the default typeface for text.
|
||||
Face font.Typeface
|
||||
|
||||
// FingerSize is the minimum touch target size.
|
||||
FingerSize unit.Dp
|
||||
}
|
||||
|
||||
// NewTheme constructs a theme (and underlying text shaper).
|
||||
func NewTheme() *Theme {
|
||||
t := &Theme{Shaper: &text.Shaper{}}
|
||||
t.Palette = Palette{
|
||||
Fg: rgb(0x000000),
|
||||
Bg: rgb(0xffffff),
|
||||
ContrastBg: rgb(0x3f51b5),
|
||||
ContrastFg: rgb(0xffffff),
|
||||
}
|
||||
t.TextSize = 16
|
||||
|
||||
t.Icon.CheckBoxChecked = mustIcon(widget.NewIcon(icons.ToggleCheckBox))
|
||||
t.Icon.CheckBoxUnchecked = mustIcon(widget.NewIcon(icons.ToggleCheckBoxOutlineBlank))
|
||||
t.Icon.RadioChecked = mustIcon(widget.NewIcon(icons.ToggleRadioButtonChecked))
|
||||
t.Icon.RadioUnchecked = mustIcon(widget.NewIcon(icons.ToggleRadioButtonUnchecked))
|
||||
|
||||
// 38dp is on the lower end of possible finger size.
|
||||
t.FingerSize = 38
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (t Theme) WithPalette(p Palette) Theme {
|
||||
t.Palette = p
|
||||
return t
|
||||
}
|
||||
|
||||
func mustIcon(ic *widget.Icon, err error) *widget.Icon {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ic
|
||||
}
|
||||
|
||||
func rgb(c uint32) color.NRGBA {
|
||||
return argb(0xff000000 | c)
|
||||
}
|
||||
|
||||
func argb(c uint32) color.NRGBA {
|
||||
return color.NRGBA{A: uint8(c >> 24), R: uint8(c >> 16), G: uint8(c >> 8), B: uint8(c)}
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"image"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/gesture"
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/event"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/pointer"
|
||||
"gioui.org/io/system"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
)
|
||||
|
||||
// stringSource is an immutable textSource with a fixed string
|
||||
// value.
|
||||
type stringSource struct {
|
||||
reader *strings.Reader
|
||||
}
|
||||
|
||||
var _ textSource = stringSource{}
|
||||
|
||||
func newStringSource(str string) stringSource {
|
||||
return stringSource{
|
||||
reader: strings.NewReader(str),
|
||||
}
|
||||
}
|
||||
|
||||
func (s stringSource) Changed() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s stringSource) Size() int64 {
|
||||
return s.reader.Size()
|
||||
}
|
||||
|
||||
func (s stringSource) ReadAt(b []byte, offset int64) (int, error) {
|
||||
return s.reader.ReadAt(b, offset)
|
||||
}
|
||||
|
||||
// ReplaceRunes is unimplemented, as a stringSource is immutable.
|
||||
func (s stringSource) ReplaceRunes(byteOffset, runeCount int64, str string) {
|
||||
}
|
||||
|
||||
// Selectable displays selectable text.
|
||||
type Selectable struct {
|
||||
// Alignment controls the alignment of the text.
|
||||
Alignment text.Alignment
|
||||
// MaxLines is the maximum number of lines of text to be displayed.
|
||||
MaxLines int
|
||||
// Truncator is the symbol to use at the end of the final line of text
|
||||
// if text was cut off. Defaults to "…" if left empty.
|
||||
Truncator string
|
||||
// WrapPolicy configures how displayed text will be broken into lines.
|
||||
WrapPolicy text.WrapPolicy
|
||||
// LineHeight controls the distance between the baselines of lines of text.
|
||||
// If zero, a sensible default will be used.
|
||||
LineHeight unit.Sp
|
||||
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
||||
// sensible default will be used.
|
||||
LineHeightScale float32
|
||||
initialized bool
|
||||
source stringSource
|
||||
// scratch is a buffer reused to efficiently read text out of the
|
||||
// textView.
|
||||
scratch []byte
|
||||
lastValue string
|
||||
text textView
|
||||
focused bool
|
||||
dragging bool
|
||||
dragger gesture.Drag
|
||||
|
||||
clicker gesture.Click
|
||||
}
|
||||
|
||||
// initialize must be called at the beginning of any exported method that
|
||||
// manipulates text state. It ensures that the underlying text is safe to
|
||||
// access.
|
||||
func (l *Selectable) initialize() {
|
||||
if !l.initialized {
|
||||
l.source = newStringSource("")
|
||||
l.text.SetSource(l.source)
|
||||
l.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
// Focused returns whether the label is focused or not.
|
||||
func (l *Selectable) Focused() bool {
|
||||
return l.focused
|
||||
}
|
||||
|
||||
// paintSelection paints the contrasting background for selected text.
|
||||
func (l *Selectable) paintSelection(gtx layout.Context, material op.CallOp) {
|
||||
l.initialize()
|
||||
if !l.focused {
|
||||
return
|
||||
}
|
||||
l.text.PaintSelection(gtx, material)
|
||||
}
|
||||
|
||||
// paintText paints the text glyphs with the provided material.
|
||||
func (l *Selectable) paintText(gtx layout.Context, material op.CallOp) {
|
||||
l.initialize()
|
||||
l.text.PaintText(gtx, material)
|
||||
}
|
||||
|
||||
// SelectionLen returns the length of the selection, in runes; it is
|
||||
// equivalent to utf8.RuneCountInString(e.SelectedText()).
|
||||
func (l *Selectable) SelectionLen() int {
|
||||
l.initialize()
|
||||
return l.text.SelectionLen()
|
||||
}
|
||||
|
||||
// Selection returns the start and end of the selection, as rune offsets.
|
||||
// start can be > end.
|
||||
func (l *Selectable) Selection() (start, end int) {
|
||||
l.initialize()
|
||||
return l.text.Selection()
|
||||
}
|
||||
|
||||
// SetCaret moves the caret to start, and sets the selection end to end. start
|
||||
// and end are in runes, and represent offsets into the editor text.
|
||||
func (l *Selectable) SetCaret(start, end int) {
|
||||
l.initialize()
|
||||
l.text.SetCaret(start, end)
|
||||
}
|
||||
|
||||
// SelectedText returns the currently selected text (if any) from the editor.
|
||||
func (l *Selectable) SelectedText() string {
|
||||
l.initialize()
|
||||
l.scratch = l.text.SelectedText(l.scratch)
|
||||
return string(l.scratch)
|
||||
}
|
||||
|
||||
// ClearSelection clears the selection, by setting the selection end equal to
|
||||
// the selection start.
|
||||
func (l *Selectable) ClearSelection() {
|
||||
l.initialize()
|
||||
l.text.ClearSelection()
|
||||
}
|
||||
|
||||
// Text returns the contents of the label.
|
||||
func (l *Selectable) Text() string {
|
||||
l.initialize()
|
||||
l.scratch = l.text.Text(l.scratch)
|
||||
return string(l.scratch)
|
||||
}
|
||||
|
||||
// SetText updates the text to s if it does not already contain s. Updating the
|
||||
// text will clear the selection unless the selectable already contains s.
|
||||
func (l *Selectable) SetText(s string) {
|
||||
l.initialize()
|
||||
if l.lastValue != s {
|
||||
l.source = newStringSource(s)
|
||||
l.lastValue = s
|
||||
l.text.SetSource(l.source)
|
||||
}
|
||||
}
|
||||
|
||||
// Truncated returns whether the text has been truncated by the text shaper to
|
||||
// fit within available constraints.
|
||||
func (l *Selectable) Truncated() bool {
|
||||
return l.text.Truncated()
|
||||
}
|
||||
|
||||
// Update the state of the selectable in response to input events. It returns whether the
|
||||
// text selection changed during event processing.
|
||||
func (l *Selectable) Update(gtx layout.Context) bool {
|
||||
l.initialize()
|
||||
return l.handleEvents(gtx)
|
||||
}
|
||||
|
||||
// Layout clips to the dimensions of the selectable, updates the shaped text, configures input handling, and paints
|
||||
// the text and selection rectangles. The provided textMaterial and selectionMaterial ops are used to set the
|
||||
// paint material for the text and selection rectangles, respectively.
|
||||
func (l *Selectable) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp, textMaterial, selectionMaterial op.CallOp) layout.Dimensions {
|
||||
l.Update(gtx)
|
||||
l.text.LineHeight = l.LineHeight
|
||||
l.text.LineHeightScale = l.LineHeightScale
|
||||
l.text.Alignment = l.Alignment
|
||||
l.text.MaxLines = l.MaxLines
|
||||
l.text.Truncator = l.Truncator
|
||||
l.text.WrapPolicy = l.WrapPolicy
|
||||
l.text.Layout(gtx, lt, font, size)
|
||||
dims := l.text.Dimensions()
|
||||
defer clip.Rect(image.Rectangle{Max: dims.Size}).Push(gtx.Ops).Pop()
|
||||
pointer.CursorText.Add(gtx.Ops)
|
||||
event.Op(gtx.Ops, l)
|
||||
|
||||
l.clicker.Add(gtx.Ops)
|
||||
l.dragger.Add(gtx.Ops)
|
||||
|
||||
l.paintSelection(gtx, selectionMaterial)
|
||||
l.paintText(gtx, textMaterial)
|
||||
return dims
|
||||
}
|
||||
|
||||
func (l *Selectable) handleEvents(gtx layout.Context) (selectionChanged bool) {
|
||||
oldStart, oldLen := min(l.text.Selection()), l.text.SelectionLen()
|
||||
defer func() {
|
||||
if newStart, newLen := min(l.text.Selection()), l.text.SelectionLen(); oldStart != newStart || oldLen != newLen {
|
||||
selectionChanged = true
|
||||
}
|
||||
}()
|
||||
l.processPointer(gtx)
|
||||
l.processKey(gtx)
|
||||
return selectionChanged
|
||||
}
|
||||
|
||||
func (e *Selectable) processPointer(gtx layout.Context) {
|
||||
for _, evt := range e.clickDragEvents(gtx) {
|
||||
switch evt := evt.(type) {
|
||||
case gesture.ClickEvent:
|
||||
switch {
|
||||
case evt.Kind == gesture.KindPress && evt.Source == pointer.Mouse,
|
||||
evt.Kind == gesture.KindClick && evt.Source != pointer.Mouse:
|
||||
prevCaretPos, _ := e.text.Selection()
|
||||
e.text.MoveCoord(image.Point{
|
||||
X: int(math.Round(float64(evt.Position.X))),
|
||||
Y: int(math.Round(float64(evt.Position.Y))),
|
||||
})
|
||||
gtx.Execute(key.FocusCmd{Tag: e})
|
||||
if evt.Modifiers == key.ModShift {
|
||||
start, end := e.text.Selection()
|
||||
// If they clicked closer to the end, then change the end to
|
||||
// where the caret used to be (effectively swapping start & end).
|
||||
if abs(end-start) < abs(start-prevCaretPos) {
|
||||
e.text.SetCaret(start, prevCaretPos)
|
||||
}
|
||||
} else {
|
||||
e.text.ClearSelection()
|
||||
}
|
||||
e.dragging = true
|
||||
|
||||
// Process multi-clicks.
|
||||
switch {
|
||||
case evt.NumClicks == 2:
|
||||
e.text.MoveWord(-1, selectionClear)
|
||||
e.text.MoveWord(1, selectionExtend)
|
||||
e.dragging = false
|
||||
case evt.NumClicks >= 3:
|
||||
e.text.MoveLineStart(selectionClear)
|
||||
e.text.MoveLineEnd(selectionExtend)
|
||||
e.dragging = false
|
||||
}
|
||||
}
|
||||
case pointer.Event:
|
||||
release := false
|
||||
switch {
|
||||
case evt.Kind == pointer.Release && evt.Source == pointer.Mouse:
|
||||
release = true
|
||||
fallthrough
|
||||
case evt.Kind == pointer.Drag && evt.Source == pointer.Mouse:
|
||||
if e.dragging {
|
||||
e.text.MoveCoord(image.Point{
|
||||
X: int(math.Round(float64(evt.Position.X))),
|
||||
Y: int(math.Round(float64(evt.Position.Y))),
|
||||
})
|
||||
|
||||
if release {
|
||||
e.dragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Selectable) clickDragEvents(gtx layout.Context) []event.Event {
|
||||
var combinedEvents []event.Event
|
||||
for {
|
||||
evt, ok := e.clicker.Update(gtx.Source)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
combinedEvents = append(combinedEvents, evt)
|
||||
}
|
||||
for {
|
||||
evt, ok := e.dragger.Update(gtx.Metric, gtx.Source, gesture.Both)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
combinedEvents = append(combinedEvents, evt)
|
||||
}
|
||||
return combinedEvents
|
||||
}
|
||||
|
||||
func (e *Selectable) processKey(gtx layout.Context) {
|
||||
for {
|
||||
ke, ok := gtx.Event(
|
||||
key.FocusFilter{Target: e},
|
||||
key.Filter{Focus: e, Name: key.NameLeftArrow, Optional: key.ModShortcutAlt | key.ModShift},
|
||||
key.Filter{Focus: e, Name: key.NameRightArrow, Optional: key.ModShortcutAlt | key.ModShift},
|
||||
key.Filter{Focus: e, Name: key.NameUpArrow, Optional: key.ModShortcutAlt | key.ModShift},
|
||||
key.Filter{Focus: e, Name: key.NameDownArrow, Optional: key.ModShortcutAlt | key.ModShift},
|
||||
|
||||
key.Filter{Focus: e, Name: key.NamePageUp, Optional: key.ModShift},
|
||||
key.Filter{Focus: e, Name: key.NamePageDown, Optional: key.ModShift},
|
||||
key.Filter{Focus: e, Name: key.NameEnd, Optional: key.ModShift},
|
||||
key.Filter{Focus: e, Name: key.NameHome, Optional: key.ModShift},
|
||||
|
||||
key.Filter{Focus: e, Name: "C", Required: key.ModShortcut},
|
||||
key.Filter{Focus: e, Name: "X", Required: key.ModShortcut},
|
||||
key.Filter{Focus: e, Name: "A", Required: key.ModShortcut},
|
||||
)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch ke := ke.(type) {
|
||||
case key.FocusEvent:
|
||||
e.focused = ke.Focus
|
||||
case key.Event:
|
||||
if !e.focused || ke.State != key.Press {
|
||||
break
|
||||
}
|
||||
e.command(gtx, ke)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Selectable) command(gtx layout.Context, k key.Event) {
|
||||
direction := 1
|
||||
if gtx.Locale.Direction.Progression() == system.TowardOrigin {
|
||||
direction = -1
|
||||
}
|
||||
moveByWord := k.Modifiers.Contain(key.ModShortcutAlt)
|
||||
selAct := selectionClear
|
||||
if k.Modifiers.Contain(key.ModShift) {
|
||||
selAct = selectionExtend
|
||||
}
|
||||
if k.Modifiers == key.ModShortcut {
|
||||
switch k.Name {
|
||||
// Copy or Cut selection -- ignored if nothing selected.
|
||||
case "C", "X":
|
||||
e.scratch = e.text.SelectedText(e.scratch)
|
||||
if text := string(e.scratch); text != "" {
|
||||
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(text))})
|
||||
}
|
||||
// Select all
|
||||
case "A":
|
||||
e.text.SetCaret(0, e.text.Len())
|
||||
}
|
||||
return
|
||||
}
|
||||
switch k.Name {
|
||||
case key.NameUpArrow:
|
||||
e.text.MoveLines(-1, selAct)
|
||||
case key.NameDownArrow:
|
||||
e.text.MoveLines(+1, selAct)
|
||||
case key.NameLeftArrow:
|
||||
if moveByWord {
|
||||
e.text.MoveWord(-1*direction, selAct)
|
||||
} else {
|
||||
if selAct == selectionClear {
|
||||
e.text.ClearSelection()
|
||||
}
|
||||
e.text.MoveCaret(-1*direction, -1*direction*int(selAct))
|
||||
}
|
||||
case key.NameRightArrow:
|
||||
if moveByWord {
|
||||
e.text.MoveWord(1*direction, selAct)
|
||||
} else {
|
||||
if selAct == selectionClear {
|
||||
e.text.ClearSelection()
|
||||
}
|
||||
e.text.MoveCaret(1*direction, int(selAct)*direction)
|
||||
}
|
||||
case key.NamePageUp:
|
||||
e.text.MovePages(-1, selAct)
|
||||
case key.NamePageDown:
|
||||
e.text.MovePages(+1, selAct)
|
||||
case key.NameHome:
|
||||
e.text.MoveLineStart(selAct)
|
||||
case key.NameEnd:
|
||||
e.text.MoveLineEnd(selAct)
|
||||
}
|
||||
}
|
||||
|
||||
// Regions returns visible regions covering the rune range [start,end).
|
||||
func (l *Selectable) Regions(start, end int, regions []Region) []Region {
|
||||
l.initialize()
|
||||
return l.text.Regions(start, end, regions)
|
||||
}
|
||||
+853
@@ -0,0 +1,853 @@
|
||||
package widget
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"image"
|
||||
"io"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gioui.org/f32"
|
||||
"gioui.org/font"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
// textSource provides text data for use in widgets. If the underlying data type
|
||||
// can fail due to I/O errors, it is the responsibility of that type to provide
|
||||
// its own mechanism to surface and handle those errors. They will not always
|
||||
// be returned by widgets using these functions.
|
||||
type textSource interface {
|
||||
io.ReaderAt
|
||||
// Size returns the total length of the data in bytes.
|
||||
Size() int64
|
||||
// Changed returns whether the contents have changed since the last call
|
||||
// to Changed.
|
||||
Changed() bool
|
||||
// ReplaceRunes replaces runeCount runes starting at byteOffset within the
|
||||
// data with the provided string. Implementations of read-only text sources
|
||||
// are free to make this a no-op.
|
||||
ReplaceRunes(byteOffset int64, runeCount int64, replacement string)
|
||||
}
|
||||
|
||||
// textView provides efficient shaping and indexing of interactive text. When provided
|
||||
// with a TextSource, textView will shape and cache the runes within that source.
|
||||
// It provides methods for configuring a viewport onto the shaped text which can
|
||||
// be scrolled, and for configuring and drawing text selection boxes.
|
||||
type textView struct {
|
||||
Alignment text.Alignment
|
||||
// LineHeight controls the distance between the baselines of lines of text.
|
||||
// If zero, a sensible default will be used.
|
||||
LineHeight unit.Sp
|
||||
// LineHeightScale applies a scaling factor to the LineHeight. If zero, a
|
||||
// sensible default will be used.
|
||||
LineHeightScale float32
|
||||
// SingleLine forces the text to stay on a single line.
|
||||
// SingleLine also sets the scrolling direction to
|
||||
// horizontal.
|
||||
SingleLine bool
|
||||
// MaxLines limits the shaped text to a specific quantity of shaped lines.
|
||||
MaxLines int
|
||||
// Truncator is the text that will be shown at the end of the final
|
||||
// line if MaxLines is exceeded. Defaults to "…" if empty.
|
||||
Truncator string
|
||||
// WrapPolicy configures how displayed text will be broken into lines.
|
||||
WrapPolicy text.WrapPolicy
|
||||
// DisableSpaceTrim configures whether trailing whitespace on a line will have its
|
||||
// width zeroed. Set to true for editors, but false for non-editable text.
|
||||
DisableSpaceTrim bool
|
||||
// Mask replaces the visual display of each rune in the contents with the given rune.
|
||||
// Newline characters are not masked. When non-zero, the unmasked contents
|
||||
// are accessed by Len, Text, and SetText.
|
||||
Mask rune
|
||||
|
||||
params text.Parameters
|
||||
shaper *text.Shaper
|
||||
seekCursor int64
|
||||
rr textSource
|
||||
maskReader maskReader
|
||||
// graphemes tracks the indices of grapheme cluster boundaries within rr.
|
||||
graphemes []int
|
||||
// paragraphReader is used to populate graphemes.
|
||||
paragraphReader graphemeReader
|
||||
lastMask rune
|
||||
viewSize image.Point
|
||||
valid bool
|
||||
regions []Region
|
||||
dims layout.Dimensions
|
||||
|
||||
// offIndex is an index of rune index to byte offsets.
|
||||
offIndex []offEntry
|
||||
|
||||
index glyphIndex
|
||||
|
||||
caret struct {
|
||||
// xoff is the offset to the current position when moving between lines.
|
||||
xoff fixed.Int26_6
|
||||
// start is the current caret position in runes, and also the start position of
|
||||
// selected text. end is the end position of selected text. If start
|
||||
// == end, then there's no selection. Note that it's possible (and
|
||||
// common) that the caret (start) is after the end, e.g. after
|
||||
// Shift-DownArrow.
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
scrollOff image.Point
|
||||
}
|
||||
|
||||
func (e *textView) Changed() bool {
|
||||
return e.rr.Changed()
|
||||
}
|
||||
|
||||
// Dimensions returns the dimensions of the visible text.
|
||||
func (e *textView) Dimensions() layout.Dimensions {
|
||||
basePos := e.dims.Size.Y - e.dims.Baseline
|
||||
return layout.Dimensions{Size: e.viewSize, Baseline: e.viewSize.Y - basePos}
|
||||
}
|
||||
|
||||
// FullDimensions returns the dimensions of all shaped text, including
|
||||
// text that isn't visible within the current viewport.
|
||||
func (e *textView) FullDimensions() layout.Dimensions {
|
||||
return e.dims
|
||||
}
|
||||
|
||||
// SetSource initializes the underlying data source for the Text. This
|
||||
// must be done before invoking any other methods on Text.
|
||||
func (e *textView) SetSource(source textSource) {
|
||||
e.rr = source
|
||||
e.invalidate()
|
||||
e.seekCursor = 0
|
||||
}
|
||||
|
||||
// ReadRuneAt reads the rune starting at the given byte offset, if any.
|
||||
func (e *textView) ReadRuneAt(off int64) (rune, int, error) {
|
||||
var buf [utf8.UTFMax]byte
|
||||
b := buf[:]
|
||||
n, err := e.rr.ReadAt(b, off)
|
||||
b = b[:n]
|
||||
r, s := utf8.DecodeRune(b)
|
||||
return r, s, err
|
||||
}
|
||||
|
||||
// ReadRuneAt reads the run prior to the given byte offset, if any.
|
||||
func (e *textView) ReadRuneBefore(off int64) (rune, int, error) {
|
||||
var buf [utf8.UTFMax]byte
|
||||
b := buf[:]
|
||||
if off < utf8.UTFMax {
|
||||
b = b[:off]
|
||||
off = 0
|
||||
} else {
|
||||
off -= utf8.UTFMax
|
||||
}
|
||||
n, err := e.rr.ReadAt(b, off)
|
||||
b = b[:n]
|
||||
r, s := utf8.DecodeLastRune(b)
|
||||
return r, s, err
|
||||
}
|
||||
|
||||
func (e *textView) makeValid() {
|
||||
if e.valid {
|
||||
return
|
||||
}
|
||||
e.layoutText(e.shaper)
|
||||
e.valid = true
|
||||
}
|
||||
|
||||
func (e *textView) closestToRune(runeIdx int) combinedPos {
|
||||
e.makeValid()
|
||||
pos, _ := e.index.closestToRune(runeIdx)
|
||||
return pos
|
||||
}
|
||||
|
||||
func (e *textView) closestToLineCol(line, col int) combinedPos {
|
||||
e.makeValid()
|
||||
return e.index.closestToLineCol(screenPos{line: line, col: col})
|
||||
}
|
||||
|
||||
func (e *textView) closestToXY(x fixed.Int26_6, y int) (combinedPos, bool) {
|
||||
e.makeValid()
|
||||
return e.index.closestToXY(x, y)
|
||||
}
|
||||
|
||||
func (e *textView) closestToXYGraphemes(x fixed.Int26_6, y int) (combinedPos, bool) {
|
||||
// Find the closest existing rune position to the provided coordinates.
|
||||
pos, atEndOfLine := e.closestToXY(x, y)
|
||||
if atEndOfLine {
|
||||
return pos, true
|
||||
}
|
||||
// Resolve cluster boundaries on either side of the rune position.
|
||||
firstOption := e.moveByGraphemes(pos.runes, 0)
|
||||
distance := 1
|
||||
if firstOption > pos.runes {
|
||||
distance = -1
|
||||
}
|
||||
secondOption := e.moveByGraphemes(firstOption, distance)
|
||||
// Choose the closest grapheme cluster boundary to the desired point.
|
||||
first := e.closestToRune(firstOption)
|
||||
firstDist := absFixed(first.x - x)
|
||||
second := e.closestToRune(secondOption)
|
||||
secondDist := absFixed(second.x - x)
|
||||
if firstDist > secondDist {
|
||||
return second, false
|
||||
} else {
|
||||
return first, false
|
||||
}
|
||||
}
|
||||
|
||||
func absFixed(i fixed.Int26_6) fixed.Int26_6 {
|
||||
if i < 0 {
|
||||
return -i
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// MaxLines moves the cursor the specified number of lines vertically, ensuring
|
||||
// that the resulting position is aligned to a grapheme cluster.
|
||||
func (e *textView) MoveLines(distance int, selAct selectionAction) {
|
||||
caretStart := e.closestToRune(e.caret.start)
|
||||
x := caretStart.x + e.caret.xoff
|
||||
// Seek to line.
|
||||
pos := e.closestToLineCol(caretStart.lineCol.line+distance, 0)
|
||||
pos, atEndOfLine := e.closestToXYGraphemes(x, pos.y)
|
||||
e.caret.start = pos.runes
|
||||
if atEndOfLine && pos.runes > 0 {
|
||||
e.caret.start = pos.runes - 1
|
||||
}
|
||||
e.caret.xoff = x - pos.x
|
||||
e.updateSelection(selAct)
|
||||
}
|
||||
|
||||
// calculateViewSize determines the size of the current visible content,
|
||||
// ensuring that even if there is no text content, some space is reserved
|
||||
// for the caret.
|
||||
func (e *textView) calculateViewSize(gtx layout.Context) image.Point {
|
||||
base := e.dims.Size
|
||||
if caretWidth := e.caretWidth(gtx); base.X < caretWidth {
|
||||
base.X = caretWidth
|
||||
}
|
||||
return gtx.Constraints.Constrain(base)
|
||||
}
|
||||
|
||||
// Layout the text, reshaping it as necessary.
|
||||
func (e *textView) Layout(gtx layout.Context, lt *text.Shaper, font font.Font, size unit.Sp) {
|
||||
if e.params.Locale != gtx.Locale {
|
||||
e.params.Locale = gtx.Locale
|
||||
e.invalidate()
|
||||
}
|
||||
textSize := fixed.I(gtx.Sp(size))
|
||||
if e.params.Font != font || e.params.PxPerEm != textSize {
|
||||
e.invalidate()
|
||||
e.params.Font = font
|
||||
e.params.PxPerEm = textSize
|
||||
}
|
||||
maxWidth := gtx.Constraints.Max.X
|
||||
if e.SingleLine {
|
||||
maxWidth = math.MaxInt
|
||||
}
|
||||
minWidth := gtx.Constraints.Min.X
|
||||
if maxWidth != e.params.MaxWidth {
|
||||
e.params.MaxWidth = maxWidth
|
||||
e.invalidate()
|
||||
}
|
||||
if minWidth != e.params.MinWidth {
|
||||
e.params.MinWidth = minWidth
|
||||
e.invalidate()
|
||||
}
|
||||
if lt != e.shaper {
|
||||
e.shaper = lt
|
||||
e.invalidate()
|
||||
}
|
||||
if e.Mask != e.lastMask {
|
||||
e.lastMask = e.Mask
|
||||
e.invalidate()
|
||||
}
|
||||
if e.Alignment != e.params.Alignment {
|
||||
e.params.Alignment = e.Alignment
|
||||
e.invalidate()
|
||||
}
|
||||
if e.Truncator != e.params.Truncator {
|
||||
e.params.Truncator = e.Truncator
|
||||
e.invalidate()
|
||||
}
|
||||
if e.MaxLines != e.params.MaxLines {
|
||||
e.params.MaxLines = e.MaxLines
|
||||
e.invalidate()
|
||||
}
|
||||
if e.WrapPolicy != e.params.WrapPolicy {
|
||||
e.params.WrapPolicy = e.WrapPolicy
|
||||
e.invalidate()
|
||||
}
|
||||
if lh := fixed.I(gtx.Sp(e.LineHeight)); lh != e.params.LineHeight {
|
||||
e.params.LineHeight = lh
|
||||
e.invalidate()
|
||||
}
|
||||
if e.LineHeightScale != e.params.LineHeightScale {
|
||||
e.params.LineHeightScale = e.LineHeightScale
|
||||
e.invalidate()
|
||||
}
|
||||
if e.DisableSpaceTrim != e.params.DisableSpaceTrim {
|
||||
e.params.DisableSpaceTrim = e.DisableSpaceTrim
|
||||
e.invalidate()
|
||||
}
|
||||
|
||||
e.makeValid()
|
||||
|
||||
if viewSize := e.calculateViewSize(gtx); viewSize != e.viewSize {
|
||||
e.viewSize = viewSize
|
||||
e.invalidate()
|
||||
}
|
||||
e.makeValid()
|
||||
}
|
||||
|
||||
// PaintSelection clips and paints the visible text selection rectangles using
|
||||
// the provided material to fill the rectangles.
|
||||
func (e *textView) PaintSelection(gtx layout.Context, material op.CallOp) {
|
||||
localViewport := image.Rectangle{Max: e.viewSize}
|
||||
docViewport := image.Rectangle{Max: e.viewSize}.Add(e.scrollOff)
|
||||
defer clip.Rect(localViewport).Push(gtx.Ops).Pop()
|
||||
e.regions = e.index.locate(docViewport, e.caret.start, e.caret.end, e.regions)
|
||||
for _, region := range e.regions {
|
||||
area := clip.Rect(region.Bounds).Push(gtx.Ops)
|
||||
material.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
area.Pop()
|
||||
}
|
||||
}
|
||||
|
||||
// PaintText clips and paints the visible text glyph outlines using the provided
|
||||
// material to fill the glyphs.
|
||||
func (e *textView) PaintText(gtx layout.Context, material op.CallOp) {
|
||||
m := op.Record(gtx.Ops)
|
||||
viewport := image.Rectangle{
|
||||
Min: e.scrollOff,
|
||||
Max: e.viewSize.Add(e.scrollOff),
|
||||
}
|
||||
it := textIterator{
|
||||
viewport: viewport,
|
||||
material: material,
|
||||
}
|
||||
|
||||
startGlyph := 0
|
||||
for _, line := range e.index.lines {
|
||||
if line.descent.Ceil()+line.yOff >= viewport.Min.Y {
|
||||
break
|
||||
}
|
||||
startGlyph += line.glyphs
|
||||
}
|
||||
var glyphs [32]text.Glyph
|
||||
line := glyphs[:0]
|
||||
for _, g := range e.index.glyphs[startGlyph:] {
|
||||
var ok bool
|
||||
if line, ok = it.paintGlyph(gtx, e.shaper, g, line); !ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
call := m.Stop()
|
||||
viewport.Min = viewport.Min.Add(it.padding.Min)
|
||||
viewport.Max = viewport.Max.Add(it.padding.Max)
|
||||
defer clip.Rect(viewport.Sub(e.scrollOff)).Push(gtx.Ops).Pop()
|
||||
call.Add(gtx.Ops)
|
||||
}
|
||||
|
||||
// caretWidth returns the width occupied by the caret for the current
|
||||
// gtx.
|
||||
func (e *textView) caretWidth(gtx layout.Context) int {
|
||||
carWidth2 := max(gtx.Dp(1)/2, 1)
|
||||
return carWidth2
|
||||
}
|
||||
|
||||
// PaintCaret clips and paints the caret rectangle, adding material immediately
|
||||
// before painting to set the appropriate paint material.
|
||||
func (e *textView) PaintCaret(gtx layout.Context, material op.CallOp) {
|
||||
carWidth2 := e.caretWidth(gtx)
|
||||
caretPos, carAsc, carDesc := e.CaretInfo()
|
||||
|
||||
carRect := image.Rectangle{
|
||||
Min: caretPos.Sub(image.Pt(carWidth2, carAsc)),
|
||||
Max: caretPos.Add(image.Pt(carWidth2, carDesc)),
|
||||
}
|
||||
cl := image.Rectangle{Max: e.viewSize}
|
||||
carRect = cl.Intersect(carRect)
|
||||
if !carRect.Empty() {
|
||||
defer clip.Rect(carRect).Push(gtx.Ops).Pop()
|
||||
material.Add(gtx.Ops)
|
||||
paint.PaintOp{}.Add(gtx.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *textView) CaretInfo() (pos image.Point, ascent, descent int) {
|
||||
caretStart := e.closestToRune(e.caret.start)
|
||||
|
||||
ascent = caretStart.ascent.Ceil()
|
||||
descent = caretStart.descent.Ceil()
|
||||
|
||||
pos = image.Point{
|
||||
X: caretStart.x.Round(),
|
||||
Y: caretStart.y,
|
||||
}
|
||||
pos = pos.Sub(e.scrollOff)
|
||||
return
|
||||
}
|
||||
|
||||
// ByteOffset returns the start byte of the rune at the given
|
||||
// rune offset, clamped to the size of the text.
|
||||
func (e *textView) ByteOffset(runeOffset int) int64 {
|
||||
return int64(e.runeOffset(e.closestToRune(runeOffset).runes))
|
||||
}
|
||||
|
||||
// Len is the length of the editor contents, in runes.
|
||||
func (e *textView) Len() int {
|
||||
e.makeValid()
|
||||
return e.closestToRune(math.MaxInt).runes
|
||||
}
|
||||
|
||||
// Text returns the contents of the editor. If the provided buf is large enough, it will
|
||||
// be filled and returned. Otherwise a new buffer will be allocated.
|
||||
// Callers can guarantee that buf is large enough by giving it capacity e.Len()*utf8.UTFMax.
|
||||
func (e *textView) Text(buf []byte) []byte {
|
||||
size := e.rr.Size()
|
||||
if cap(buf) < int(size) {
|
||||
buf = make([]byte, size)
|
||||
}
|
||||
buf = buf[:size]
|
||||
e.Seek(0, io.SeekStart)
|
||||
n, _ := io.ReadFull(e, buf)
|
||||
buf = buf[:n]
|
||||
return buf
|
||||
}
|
||||
|
||||
func (e *textView) ScrollBounds() image.Rectangle {
|
||||
var b image.Rectangle
|
||||
if e.SingleLine {
|
||||
if len(e.index.lines) > 0 {
|
||||
line := e.index.lines[0]
|
||||
b.Min.X = min(line.xOff.Floor(), 0)
|
||||
}
|
||||
b.Max.X = e.dims.Size.X + b.Min.X - e.viewSize.X
|
||||
} else {
|
||||
b.Max.Y = e.dims.Size.Y - e.viewSize.Y
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (e *textView) ScrollRel(dx, dy int) {
|
||||
e.scrollAbs(e.scrollOff.X+dx, e.scrollOff.Y+dy)
|
||||
}
|
||||
|
||||
// ScrollOff returns the scroll offset of the text viewport.
|
||||
func (e *textView) ScrollOff() image.Point {
|
||||
return e.scrollOff
|
||||
}
|
||||
|
||||
func (e *textView) scrollAbs(x, y int) {
|
||||
e.scrollOff.X = x
|
||||
e.scrollOff.Y = y
|
||||
b := e.ScrollBounds()
|
||||
if e.scrollOff.X > b.Max.X {
|
||||
e.scrollOff.X = b.Max.X
|
||||
}
|
||||
if e.scrollOff.X < b.Min.X {
|
||||
e.scrollOff.X = b.Min.X
|
||||
}
|
||||
if e.scrollOff.Y > b.Max.Y {
|
||||
e.scrollOff.Y = b.Max.Y
|
||||
}
|
||||
if e.scrollOff.Y < b.Min.Y {
|
||||
e.scrollOff.Y = b.Min.Y
|
||||
}
|
||||
}
|
||||
|
||||
// MoveCoord moves the caret to the position closest to the provided
|
||||
// point that is aligned to a grapheme cluster boundary.
|
||||
func (e *textView) MoveCoord(pos image.Point) {
|
||||
x := fixed.I(pos.X + e.scrollOff.X)
|
||||
y := pos.Y + e.scrollOff.Y
|
||||
p, _ := e.closestToXYGraphemes(x, y)
|
||||
e.caret.start = p.runes
|
||||
e.caret.xoff = 0
|
||||
}
|
||||
|
||||
// Truncated returns whether the text in the textView is currently
|
||||
// truncated due to a restriction on the number of lines.
|
||||
func (e *textView) Truncated() bool {
|
||||
return e.index.truncated
|
||||
}
|
||||
|
||||
func (e *textView) layoutText(lt *text.Shaper) {
|
||||
e.Seek(0, io.SeekStart)
|
||||
var r io.Reader = e
|
||||
if e.Mask != 0 {
|
||||
e.maskReader.Reset(e, e.Mask)
|
||||
r = &e.maskReader
|
||||
}
|
||||
e.index.reset()
|
||||
it := textIterator{viewport: image.Rectangle{Max: image.Point{X: math.MaxInt, Y: math.MaxInt}}}
|
||||
if lt != nil {
|
||||
lt.Layout(e.params, r)
|
||||
for {
|
||||
g, ok := lt.NextGlyph()
|
||||
if !it.processGlyph(g, ok) {
|
||||
break
|
||||
}
|
||||
e.index.Glyph(g)
|
||||
}
|
||||
} else {
|
||||
// Make a fake glyph for every rune in the reader.
|
||||
b := bufio.NewReader(r)
|
||||
for _, _, err := b.ReadRune(); err != io.EOF; _, _, err = b.ReadRune() {
|
||||
g := text.Glyph{Runes: 1, Flags: text.FlagClusterBreak}
|
||||
_ = it.processGlyph(g, true)
|
||||
e.index.Glyph(g)
|
||||
}
|
||||
}
|
||||
e.paragraphReader.SetSource(e.rr)
|
||||
e.graphemes = e.graphemes[:0]
|
||||
for g := e.paragraphReader.Graphemes(); len(g) > 0; g = e.paragraphReader.Graphemes() {
|
||||
if len(e.graphemes) > 0 && g[0] == e.graphemes[len(e.graphemes)-1] {
|
||||
g = g[1:]
|
||||
}
|
||||
e.graphemes = append(e.graphemes, g...)
|
||||
}
|
||||
dims := layout.Dimensions{Size: it.bounds.Size()}
|
||||
dims.Baseline = dims.Size.Y - it.baseline
|
||||
e.dims = dims
|
||||
}
|
||||
|
||||
// CaretPos returns the line & column numbers of the caret.
|
||||
func (e *textView) CaretPos() (line, col int) {
|
||||
pos := e.closestToRune(e.caret.start)
|
||||
return pos.lineCol.line, pos.lineCol.col
|
||||
}
|
||||
|
||||
// CaretCoords returns the coordinates of the caret, relative to the
|
||||
// editor itself.
|
||||
func (e *textView) CaretCoords() f32.Point {
|
||||
pos := e.closestToRune(e.caret.start)
|
||||
return f32.Pt(float32(pos.x)/64-float32(e.scrollOff.X), float32(pos.y-e.scrollOff.Y))
|
||||
}
|
||||
|
||||
// indexRune returns the latest rune index and byte offset no later than r.
|
||||
func (e *textView) indexRune(r int) offEntry {
|
||||
// Initialize index.
|
||||
if len(e.offIndex) == 0 {
|
||||
e.offIndex = append(e.offIndex, offEntry{})
|
||||
}
|
||||
i := sort.Search(len(e.offIndex), func(i int) bool {
|
||||
entry := e.offIndex[i]
|
||||
return entry.runes >= r
|
||||
})
|
||||
// Return the entry guaranteed to be less than or equal to r.
|
||||
if i > 0 {
|
||||
i--
|
||||
}
|
||||
return e.offIndex[i]
|
||||
}
|
||||
|
||||
// runeOffset returns the byte offset into e.rr of the r'th rune.
|
||||
// r must be a valid rune index, usually returned by closestPosition.
|
||||
func (e *textView) runeOffset(r int) int {
|
||||
const runesPerIndexEntry = 50
|
||||
entry := e.indexRune(r)
|
||||
lastEntry := e.offIndex[len(e.offIndex)-1].runes
|
||||
for entry.runes < r {
|
||||
if entry.runes > lastEntry && entry.runes%runesPerIndexEntry == runesPerIndexEntry-1 {
|
||||
e.offIndex = append(e.offIndex, entry)
|
||||
}
|
||||
_, s, _ := e.ReadRuneAt(int64(entry.bytes))
|
||||
entry.bytes += s
|
||||
entry.runes++
|
||||
}
|
||||
return entry.bytes
|
||||
}
|
||||
|
||||
func (e *textView) invalidate() {
|
||||
e.offIndex = e.offIndex[:0]
|
||||
e.valid = false
|
||||
}
|
||||
|
||||
// Replace the text between start and end with s. Indices are in runes.
|
||||
// It returns the number of runes inserted.
|
||||
func (e *textView) Replace(start, end int, s string) int {
|
||||
if start > end {
|
||||
start, end = end, start
|
||||
}
|
||||
startPos := e.closestToRune(start)
|
||||
endPos := e.closestToRune(end)
|
||||
startOff := e.runeOffset(startPos.runes)
|
||||
replaceSize := endPos.runes - startPos.runes
|
||||
sc := utf8.RuneCountInString(s)
|
||||
newEnd := startPos.runes + sc
|
||||
|
||||
e.rr.ReplaceRunes(int64(startOff), int64(replaceSize), s)
|
||||
adjust := func(pos int) int {
|
||||
switch {
|
||||
case newEnd < pos && pos <= endPos.runes:
|
||||
pos = newEnd
|
||||
case endPos.runes < pos:
|
||||
diff := newEnd - endPos.runes
|
||||
pos = pos + diff
|
||||
}
|
||||
return pos
|
||||
}
|
||||
e.caret.start = adjust(e.caret.start)
|
||||
e.caret.end = adjust(e.caret.end)
|
||||
e.invalidate()
|
||||
return sc
|
||||
}
|
||||
|
||||
// MovePages moves the caret position by vertical pages of text, ensuring that
|
||||
// the final position is aligned to a grapheme cluster boundary.
|
||||
func (e *textView) MovePages(pages int, selAct selectionAction) {
|
||||
caret := e.closestToRune(e.caret.start)
|
||||
x := caret.x + e.caret.xoff
|
||||
y := caret.y + pages*e.viewSize.Y
|
||||
pos, _ := e.closestToXYGraphemes(x, y)
|
||||
e.caret.start = pos.runes
|
||||
e.caret.xoff = x - pos.x
|
||||
e.updateSelection(selAct)
|
||||
}
|
||||
|
||||
// moveByGraphemes returns the rune index resulting from moving the
|
||||
// specified number of grapheme clusters from startRuneidx.
|
||||
func (e *textView) moveByGraphemes(startRuneidx, graphemes int) int {
|
||||
if len(e.graphemes) == 0 {
|
||||
return startRuneidx
|
||||
}
|
||||
startGraphemeIdx, _ := slices.BinarySearch(e.graphemes, startRuneidx)
|
||||
startGraphemeIdx = max(startGraphemeIdx+graphemes, 0)
|
||||
startGraphemeIdx = min(startGraphemeIdx, len(e.graphemes)-1)
|
||||
startRuneIdx := e.graphemes[startGraphemeIdx]
|
||||
return e.closestToRune(startRuneIdx).runes
|
||||
}
|
||||
|
||||
// clampCursorToGraphemes ensures that the final start/end positions of
|
||||
// the cursor are on grapheme cluster boundaries.
|
||||
func (e *textView) clampCursorToGraphemes() {
|
||||
e.caret.start = e.moveByGraphemes(e.caret.start, 0)
|
||||
e.caret.end = e.moveByGraphemes(e.caret.end, 0)
|
||||
}
|
||||
|
||||
// MoveCaret moves the caret (aka selection start) and the selection end
|
||||
// relative to their current positions. Positive distances moves forward,
|
||||
// negative distances moves backward. Distances are in grapheme clusters which
|
||||
// better match the expectations of users than runes.
|
||||
func (e *textView) MoveCaret(startDelta, endDelta int) {
|
||||
e.caret.xoff = 0
|
||||
e.caret.start = e.moveByGraphemes(e.caret.start, startDelta)
|
||||
e.caret.end = e.moveByGraphemes(e.caret.end, endDelta)
|
||||
}
|
||||
|
||||
// MoveTextStart moves the caret to the start of the text.
|
||||
func (e *textView) MoveTextStart(selAct selectionAction) {
|
||||
caret := e.closestToRune(e.caret.end)
|
||||
e.caret.start = 0
|
||||
e.caret.end = caret.runes
|
||||
e.caret.xoff = -caret.x
|
||||
e.updateSelection(selAct)
|
||||
e.clampCursorToGraphemes()
|
||||
}
|
||||
|
||||
// MoveTextEnd moves the caret to the end of the text.
|
||||
func (e *textView) MoveTextEnd(selAct selectionAction) {
|
||||
caret := e.closestToRune(math.MaxInt)
|
||||
e.caret.start = caret.runes
|
||||
e.caret.xoff = fixed.I(e.params.MaxWidth) - caret.x
|
||||
e.updateSelection(selAct)
|
||||
e.clampCursorToGraphemes()
|
||||
}
|
||||
|
||||
// MoveLineStart moves the caret to the start of the current line, ensuring that the resulting
|
||||
// cursor position is on a grapheme cluster boundary.
|
||||
func (e *textView) MoveLineStart(selAct selectionAction) {
|
||||
caret := e.closestToRune(e.caret.start)
|
||||
caret = e.closestToLineCol(caret.lineCol.line, 0)
|
||||
e.caret.start = caret.runes
|
||||
e.caret.xoff = -caret.x
|
||||
e.updateSelection(selAct)
|
||||
e.clampCursorToGraphemes()
|
||||
}
|
||||
|
||||
// MoveLineEnd moves the caret to the end of the current line, ensuring that the resulting
|
||||
// cursor position is on a grapheme cluster boundary.
|
||||
func (e *textView) MoveLineEnd(selAct selectionAction) {
|
||||
caret := e.closestToRune(e.caret.start)
|
||||
caret = e.closestToLineCol(caret.lineCol.line, math.MaxInt)
|
||||
e.caret.start = caret.runes
|
||||
e.caret.xoff = fixed.I(e.params.MaxWidth) - caret.x
|
||||
e.updateSelection(selAct)
|
||||
e.clampCursorToGraphemes()
|
||||
}
|
||||
|
||||
// MoveWord moves the caret to the next word in the specified direction.
|
||||
// Positive is forward, negative is backward.
|
||||
// Absolute values greater than one will skip that many words.
|
||||
// The final caret position will be aligned to a grapheme cluster boundary.
|
||||
// BUG(whereswaldon): this method's definition of a "word" is currently
|
||||
// whitespace-delimited. Languages that do not use whitespace to delimit
|
||||
// words will experience counter-intuitive behavior when navigating by
|
||||
// word.
|
||||
func (e *textView) MoveWord(distance int, selAct selectionAction) {
|
||||
// split the distance information into constituent parts to be
|
||||
// used independently.
|
||||
words, direction := distance, 1
|
||||
if distance < 0 {
|
||||
words, direction = distance*-1, -1
|
||||
}
|
||||
// atEnd if caret is at either side of the buffer.
|
||||
caret := e.closestToRune(e.caret.start)
|
||||
atEnd := func() bool {
|
||||
return caret.runes == 0 || caret.runes == e.Len()
|
||||
}
|
||||
// next returns the appropriate rune given the direction.
|
||||
next := func() (r rune) {
|
||||
off := e.runeOffset(caret.runes)
|
||||
if direction < 0 {
|
||||
r, _, _ = e.ReadRuneBefore(int64(off))
|
||||
} else {
|
||||
r, _, _ = e.ReadRuneAt(int64(off))
|
||||
}
|
||||
return r
|
||||
}
|
||||
for range words {
|
||||
for r := next(); unicode.IsSpace(r) && !atEnd(); r = next() {
|
||||
e.MoveCaret(direction, 0)
|
||||
caret = e.closestToRune(e.caret.start)
|
||||
}
|
||||
e.MoveCaret(direction, 0)
|
||||
caret = e.closestToRune(e.caret.start)
|
||||
for r := next(); !unicode.IsSpace(r) && !atEnd(); r = next() {
|
||||
e.MoveCaret(direction, 0)
|
||||
caret = e.closestToRune(e.caret.start)
|
||||
}
|
||||
}
|
||||
e.updateSelection(selAct)
|
||||
e.clampCursorToGraphemes()
|
||||
}
|
||||
|
||||
func (e *textView) ScrollToCaret() {
|
||||
caret := e.closestToRune(e.caret.start)
|
||||
if e.SingleLine {
|
||||
var dist int
|
||||
if d := caret.x.Floor() - e.scrollOff.X; d < 0 {
|
||||
dist = d
|
||||
} else if d := caret.x.Ceil() - (e.scrollOff.X + e.viewSize.X); d > 0 {
|
||||
dist = d
|
||||
}
|
||||
e.ScrollRel(dist, 0)
|
||||
} else {
|
||||
miny := caret.y - caret.ascent.Ceil()
|
||||
maxy := caret.y + caret.descent.Ceil()
|
||||
var dist int
|
||||
if d := miny - e.scrollOff.Y; d < 0 {
|
||||
dist = d
|
||||
} else if d := maxy - (e.scrollOff.Y + e.viewSize.Y); d > 0 {
|
||||
dist = d
|
||||
}
|
||||
e.ScrollRel(0, dist)
|
||||
}
|
||||
}
|
||||
|
||||
// SelectionLen returns the length of the selection, in runes; it is
|
||||
// equivalent to utf8.RuneCountInString(e.SelectedText()).
|
||||
func (e *textView) SelectionLen() int {
|
||||
return abs(e.caret.start - e.caret.end)
|
||||
}
|
||||
|
||||
// Selection returns the start and end of the selection, as rune offsets.
|
||||
// start can be > end.
|
||||
func (e *textView) Selection() (start, end int) {
|
||||
return e.caret.start, e.caret.end
|
||||
}
|
||||
|
||||
// SetCaret moves the caret to start, and sets the selection end to end. Then
|
||||
// the two ends are clamped to the nearest grapheme cluster boundary. start
|
||||
// and end are in runes, and represent offsets into the editor text.
|
||||
func (e *textView) SetCaret(start, end int) {
|
||||
e.caret.start = e.closestToRune(start).runes
|
||||
e.caret.end = e.closestToRune(end).runes
|
||||
e.clampCursorToGraphemes()
|
||||
}
|
||||
|
||||
// SelectedText returns the currently selected text (if any) from the editor,
|
||||
// filling the provided byte slice if it is large enough or allocating and
|
||||
// returning a new byte slice if the provided one is insufficient.
|
||||
// Callers can guarantee that the buf is large enough by providing a buffer
|
||||
// with capacity e.SelectionLen()*utf8.UTFMax.
|
||||
func (e *textView) SelectedText(buf []byte) []byte {
|
||||
startOff := e.runeOffset(e.caret.start)
|
||||
endOff := e.runeOffset(e.caret.end)
|
||||
start := min(startOff, endOff)
|
||||
end := max(startOff, endOff)
|
||||
if cap(buf) < end-start {
|
||||
buf = make([]byte, end-start)
|
||||
}
|
||||
buf = buf[:end-start]
|
||||
n, _ := e.rr.ReadAt(buf, int64(start))
|
||||
// There is no way to reasonably handle a read error here. We rely upon
|
||||
// implementations of textSource to provide other ways to signal errors
|
||||
// if the user cares about that, and here we use whatever data we were
|
||||
// able to read.
|
||||
return buf[:n]
|
||||
}
|
||||
|
||||
func (e *textView) updateSelection(selAct selectionAction) {
|
||||
if selAct == selectionClear {
|
||||
e.ClearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
// ClearSelection clears the selection, by setting the selection end equal to
|
||||
// the selection start.
|
||||
func (e *textView) ClearSelection() {
|
||||
e.caret.end = e.caret.start
|
||||
}
|
||||
|
||||
// WriteTo implements io.WriterTo.
|
||||
func (e *textView) WriteTo(w io.Writer) (int64, error) {
|
||||
e.Seek(0, io.SeekStart)
|
||||
return io.Copy(w, struct{ io.Reader }{e})
|
||||
}
|
||||
|
||||
// Seek implements io.Seeker.
|
||||
func (e *textView) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
e.seekCursor = offset
|
||||
case io.SeekCurrent:
|
||||
e.seekCursor += offset
|
||||
case io.SeekEnd:
|
||||
e.seekCursor = e.rr.Size() + offset
|
||||
}
|
||||
return e.seekCursor, nil
|
||||
}
|
||||
|
||||
// Read implements io.Reader.
|
||||
func (e *textView) Read(p []byte) (int, error) {
|
||||
n, err := e.rr.ReadAt(p, e.seekCursor)
|
||||
e.seekCursor += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ReadAt implements io.ReaderAt.
|
||||
func (e *textView) ReadAt(p []byte, offset int64) (int, error) {
|
||||
return e.rr.ReadAt(p, offset)
|
||||
}
|
||||
|
||||
// Regions returns visible regions covering the rune range [start,end).
|
||||
func (e *textView) Regions(start, end int, regions []Region) []Region {
|
||||
viewport := image.Rectangle{
|
||||
Min: e.scrollOff,
|
||||
Max: e.viewSize.Add(e.scrollOff),
|
||||
}
|
||||
return e.index.locate(viewport, start, end, regions)
|
||||
}
|
||||
Reference in New Issue
Block a user