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:
ila
2026-07-23 16:35:01 +08:00
co-authored by Claude Opus 4.8
parent 97c1c4a974
commit f58728cddd
972 changed files with 597802 additions and 0 deletions
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+139
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}