mirror of
https://github.com/mum4k/termdash.git
synced 2025-04-25 13:48:50 +08:00
The button in an intermediate dev state.
This commit is contained in:
parent
07d22cc28c
commit
4b4e245d60
@ -17,12 +17,15 @@
|
||||
package button
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image"
|
||||
"sync"
|
||||
|
||||
runewidth "github.com/mattn/go-runewidth"
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/canvas"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/mouse/button"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
)
|
||||
@ -48,6 +51,14 @@ type Button struct {
|
||||
// text in the text label displayed in the button.
|
||||
text string
|
||||
|
||||
// mouseFSM tracks left mouse clicks.
|
||||
mouseFSM *button.FSM
|
||||
// state is the current state of the button.
|
||||
state button.State
|
||||
|
||||
// callback gets called on each button press.
|
||||
callback CallbackFn
|
||||
|
||||
// mu protects the widget.
|
||||
mu sync.Mutex
|
||||
|
||||
@ -58,7 +69,7 @@ type Button struct {
|
||||
// New returns a new Button that will display the provided text.
|
||||
// Each press of the button will invoke the callback function.
|
||||
func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
|
||||
opt := newOptions(runewidth.StringWidth(text))
|
||||
opt := newOptions(text)
|
||||
for _, o := range opts {
|
||||
o.set(opt)
|
||||
}
|
||||
@ -66,8 +77,10 @@ func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
|
||||
return nil, err
|
||||
}
|
||||
return &Button{
|
||||
text: text,
|
||||
opts: opt,
|
||||
text: text,
|
||||
mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
|
||||
callback: cFn,
|
||||
opts: opt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -77,34 +90,83 @@ func (b *Button) Draw(cvs *canvas.Canvas) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
return errors.New("unimplemented")
|
||||
cvsAr := cvs.Area()
|
||||
|
||||
shadowAr := image.Rect(1, 1, cvsAr.Dx(), cvsAr.Dy())
|
||||
if err := cvs.SetAreaCellOpts(shadowAr, cell.BgColor(b.opts.shadowColor)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buttonAr image.Rectangle
|
||||
if b.state == button.Up {
|
||||
buttonAr = image.Rect(0, 0, cvsAr.Dx()-shadowWidth, cvsAr.Dy()-shadowWidth)
|
||||
} else {
|
||||
buttonAr = shadowAr
|
||||
}
|
||||
|
||||
if err := cvs.SetAreaCellOpts(buttonAr, cell.BgColor(b.opts.fillColor)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.mouseFSM.UpdateArea(buttonAr)
|
||||
|
||||
textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y)
|
||||
start, err := align.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := draw.Text(cvs, b.text, start,
|
||||
draw.TextOverrunMode(draw.OverrunModeThreeDot),
|
||||
draw.TextMaxX(textAr.Max.X),
|
||||
draw.TextCellOpts(cell.FgColor(b.opts.textColor)),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keyboard processes keyboard events, acts as a button press on the configured
|
||||
// Key.
|
||||
//
|
||||
// Implements widgetapi.Widget.Keyboard.
|
||||
func (*Button) Keyboard(k *terminalapi.Keyboard) error {
|
||||
return errors.New("unimplemented")
|
||||
func (b *Button) Keyboard(k *terminalapi.Keyboard) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if k.Key == b.opts.key {
|
||||
return b.callback()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mouse processes mouse events, acts as a button press if both the press and
|
||||
// the release happen inside the button.
|
||||
//
|
||||
// Implements widgetapi.Widget.Mouse.
|
||||
func (*Button) Mouse(m *terminalapi.Mouse) error {
|
||||
return errors.New("the SegmentDisplay widget doesn't support mouse events")
|
||||
func (b *Button) Mouse(m *terminalapi.Mouse) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
clicked, state := b.mouseFSM.Event(m)
|
||||
b.state = state
|
||||
if clicked {
|
||||
return b.callback()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shadowWidth is the width of the shadow under the button in cell.
|
||||
const shadowWidth = 1
|
||||
|
||||
// Options implements widgetapi.Widget.Options.
|
||||
func (b *Button) Options() widgetapi.Options {
|
||||
// No need to lock, as the height and width get fixed when New is called.
|
||||
|
||||
// TODO calculate width and set MaximumSize too.
|
||||
height := b.opts.height + 1 // One for the shadow.
|
||||
width := b.opts.width + shadowWidth
|
||||
height := b.opts.height + shadowWidth
|
||||
return widgetapi.Options{
|
||||
MinimumSize: image.Point{b.opts.width, height},
|
||||
MaximumSize: image.Point{b.opts.width, height},
|
||||
MinimumSize: image.Point{width, height},
|
||||
MaximumSize: image.Point{width, height},
|
||||
WantKeyboard: b.opts.keyScope,
|
||||
WantMouse: true,
|
||||
}
|
||||
|
@ -153,36 +153,103 @@ func TestMouse(t *testing.T) {
|
||||
func TestOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
text string
|
||||
opts []Option
|
||||
want widgetapi.Options
|
||||
}{
|
||||
{
|
||||
desc: "doesn't want keyboard by default",
|
||||
desc: "width is based on the text width by default",
|
||||
text: "hello world",
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{6, 3},
|
||||
MinimumSize: image.Point{14, 4},
|
||||
MaximumSize: image.Point{14, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeNone,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "width supports full-width unicode characters",
|
||||
text: "■㈱の世界①",
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{13, 4},
|
||||
MaximumSize: image.Point{13, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeNone,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "width specified via WidthFor",
|
||||
text: "hello",
|
||||
opts: []Option{
|
||||
WidthFor("■㈱の世界①"),
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{13, 4},
|
||||
MaximumSize: image.Point{13, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeNone,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "custom height specified",
|
||||
text: "hello",
|
||||
opts: []Option{
|
||||
Height(10),
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{8, 11},
|
||||
MaximumSize: image.Point{8, 11},
|
||||
WantKeyboard: widgetapi.KeyScopeNone,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "custom width specified",
|
||||
text: "hello",
|
||||
opts: []Option{
|
||||
Width(10),
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{11, 4},
|
||||
MaximumSize: image.Point{11, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeNone,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
desc: "doesn't want keyboard by default",
|
||||
text: "hello",
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{8, 4},
|
||||
MaximumSize: image.Point{8, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeNone,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "registers for focused keyboard events",
|
||||
text: "hello",
|
||||
opts: []Option{
|
||||
Key(keyboard.KeyEnter),
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{6, 3},
|
||||
MinimumSize: image.Point{8, 4},
|
||||
MaximumSize: image.Point{8, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeFocused,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "registers for global keyboard events",
|
||||
text: "hello",
|
||||
opts: []Option{
|
||||
GlobalKey(keyboard.KeyEnter),
|
||||
},
|
||||
want: widgetapi.Options{
|
||||
MinimumSize: image.Point{6, 3},
|
||||
WantKeyboard: widgetapi.KeyScopeFocused,
|
||||
MinimumSize: image.Point{8, 4},
|
||||
MaximumSize: image.Point{8, 4},
|
||||
WantKeyboard: widgetapi.KeyScopeGlobal,
|
||||
WantMouse: true,
|
||||
},
|
||||
},
|
||||
@ -191,7 +258,7 @@ func TestOptions(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
ct := &callbackTracker{}
|
||||
b, err := New("text", ct.callback, tc.opts...)
|
||||
b, err := New(tc.text, ct.callback, tc.opts...)
|
||||
if err != nil {
|
||||
t.Fatalf("New => unexpected error: %v", err)
|
||||
}
|
||||
|
@ -17,13 +17,17 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mum4k/termdash"
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/container"
|
||||
"github.com/mum4k/termdash/draw"
|
||||
"github.com/mum4k/termdash/terminal/termbox"
|
||||
"github.com/mum4k/termdash/terminalapi"
|
||||
"github.com/mum4k/termdash/widgets/button"
|
||||
"github.com/mum4k/termdash/widgets/segmentdisplay"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@ -35,10 +39,64 @@ func main() {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
val := 0
|
||||
display, err := segmentdisplay.New()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := display.Write([]*segmentdisplay.TextChunk{
|
||||
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
addB, err := button.New("(a)dd", func() error {
|
||||
val++
|
||||
return display.Write([]*segmentdisplay.TextChunk{
|
||||
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
|
||||
})
|
||||
},
|
||||
button.GlobalKey('a'),
|
||||
button.WidthFor("(s)ubtract"),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
subB, err := button.New("(s)ubtract", func() error {
|
||||
val--
|
||||
return display.Write([]*segmentdisplay.TextChunk{
|
||||
segmentdisplay.NewChunk(fmt.Sprintf("%d", val)),
|
||||
})
|
||||
},
|
||||
button.GlobalKey('s'),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
c, err := container.New(
|
||||
t,
|
||||
container.Border(draw.LineStyleLight),
|
||||
container.BorderTitle("PRESS Q TO QUIT"),
|
||||
container.SplitHorizontal(
|
||||
container.Top(
|
||||
container.PlaceWidget(display),
|
||||
),
|
||||
container.Bottom(
|
||||
container.SplitVertical(
|
||||
container.Left(
|
||||
container.PlaceWidget(addB),
|
||||
container.AlignHorizontal(align.HorizontalRight),
|
||||
),
|
||||
container.Right(
|
||||
container.PlaceWidget(subB),
|
||||
container.AlignHorizontal(align.HorizontalLeft),
|
||||
),
|
||||
),
|
||||
),
|
||||
container.SplitPercent(60),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/cell/runewidth"
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
)
|
||||
@ -61,43 +62,31 @@ func (o *options) validate() error {
|
||||
}
|
||||
|
||||
// newOptions returns options with the default values set.
|
||||
func newOptions(textWidth int) *options {
|
||||
func newOptions(text string) *options {
|
||||
return &options{
|
||||
fillColor: DefaultFillColor,
|
||||
textColor: DefaultTextColor,
|
||||
shadowColor: DefaultShadowColor,
|
||||
fillColor: cell.ColorNumber(117),
|
||||
textColor: cell.ColorBlack,
|
||||
shadowColor: cell.ColorNumber(240),
|
||||
height: DefaultHeight,
|
||||
width: textWidth + 2, // One empty cell on each side of the text.
|
||||
width: widthFor(text),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultFillColor is the default for the FillColor option.
|
||||
const DefaultFillColor = cell.ColorCyan
|
||||
|
||||
// FillColor sets the fill color of the button.
|
||||
// Defaults to DefaultFillColor.
|
||||
func FillColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.fillColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultTextColor is the default for the TextColor option.
|
||||
const DefaultTextColor = cell.ColorBlack
|
||||
|
||||
// TextColor sets the color of the text label in the button.
|
||||
// Defaults to DefaultTextColor.
|
||||
func TextColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.textColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultShadowColor is the default of the ShadowColor option.
|
||||
const DefaultShadowColor = cell.Color(250)
|
||||
|
||||
// ShadowColor sets the color of the shadow under the button.
|
||||
// Defaults to DefaultShadowColor.
|
||||
func ShadowColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.shadowColor = c
|
||||
@ -125,6 +114,15 @@ func Width(cells int) Option {
|
||||
})
|
||||
}
|
||||
|
||||
// WidthFor sets the width of the button as if it was displaying the provided text.
|
||||
// Useful when displaying multiple buttons with the intention to set all of
|
||||
// their sizes equal to the one with the longest text.
|
||||
func WidthFor(text string) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.width = widthFor(text)
|
||||
})
|
||||
}
|
||||
|
||||
// Key configures the keyboard key that presses the button.
|
||||
// The widget responds to this key only if its container if focused.
|
||||
// When not provided, the widget ignores all keyboard events.
|
||||
@ -141,6 +139,11 @@ func Key(k keyboard.Key) Option {
|
||||
func GlobalKey(k keyboard.Key) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.key = k
|
||||
opts.keyScope = widgetapi.KeyScopeFocused
|
||||
opts.keyScope = widgetapi.KeyScopeGlobal
|
||||
})
|
||||
}
|
||||
|
||||
// widthFor returns the required width for the specified text.
|
||||
func widthFor(text string) int {
|
||||
return runewidth.StringWidth(text) + 2 // One empty cell at each side.
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user