1
0
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:
Jakub Sobon 2019-02-23 00:07:54 -05:00
parent 07d22cc28c
commit 4b4e245d60
No known key found for this signature in database
GPG Key ID: F2451A77FB05D3B7
4 changed files with 228 additions and 38 deletions

View File

@ -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,
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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.
}