1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-28 13:48:51 +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 package button
import ( import (
"errors"
"image" "image"
"sync" "sync"
runewidth "github.com/mattn/go-runewidth" "github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/canvas" "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/terminalapi"
"github.com/mum4k/termdash/widgetapi" "github.com/mum4k/termdash/widgetapi"
) )
@ -48,6 +51,14 @@ type Button struct {
// text in the text label displayed in the button. // text in the text label displayed in the button.
text string 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 protects the widget.
mu sync.Mutex mu sync.Mutex
@ -58,7 +69,7 @@ type Button struct {
// New returns a new Button that will display the provided text. // New returns a new Button that will display the provided text.
// Each press of the button will invoke the callback function. // Each press of the button will invoke the callback function.
func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) { func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
opt := newOptions(runewidth.StringWidth(text)) opt := newOptions(text)
for _, o := range opts { for _, o := range opts {
o.set(opt) o.set(opt)
} }
@ -66,8 +77,10 @@ func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) {
return nil, err return nil, err
} }
return &Button{ return &Button{
text: text, text: text,
opts: opt, mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
callback: cFn,
opts: opt,
}, nil }, nil
} }
@ -77,34 +90,83 @@ func (b *Button) Draw(cvs *canvas.Canvas) error {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() 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 // Keyboard processes keyboard events, acts as a button press on the configured
// Key. // Key.
// //
// Implements widgetapi.Widget.Keyboard. // Implements widgetapi.Widget.Keyboard.
func (*Button) Keyboard(k *terminalapi.Keyboard) error { func (b *Button) Keyboard(k *terminalapi.Keyboard) error {
return errors.New("unimplemented") 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 // Mouse processes mouse events, acts as a button press if both the press and
// the release happen inside the button. // the release happen inside the button.
// //
// Implements widgetapi.Widget.Mouse. // Implements widgetapi.Widget.Mouse.
func (*Button) Mouse(m *terminalapi.Mouse) error { func (b *Button) Mouse(m *terminalapi.Mouse) error {
return errors.New("the SegmentDisplay widget doesn't support mouse events") 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. // Options implements widgetapi.Widget.Options.
func (b *Button) Options() widgetapi.Options { func (b *Button) Options() widgetapi.Options {
// No need to lock, as the height and width get fixed when New is called. // No need to lock, as the height and width get fixed when New is called.
// TODO calculate width and set MaximumSize too. width := b.opts.width + shadowWidth
height := b.opts.height + 1 // One for the shadow. height := b.opts.height + shadowWidth
return widgetapi.Options{ return widgetapi.Options{
MinimumSize: image.Point{b.opts.width, height}, MinimumSize: image.Point{width, height},
MaximumSize: image.Point{b.opts.width, height}, MaximumSize: image.Point{width, height},
WantKeyboard: b.opts.keyScope, WantKeyboard: b.opts.keyScope,
WantMouse: true, WantMouse: true,
} }

View File

@ -153,36 +153,103 @@ func TestMouse(t *testing.T) {
func TestOptions(t *testing.T) { func TestOptions(t *testing.T) {
tests := []struct { tests := []struct {
desc string desc string
text string
opts []Option opts []Option
want widgetapi.Options 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{ 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, WantKeyboard: widgetapi.KeyScopeNone,
WantMouse: true, WantMouse: true,
}, },
}, },
{ {
desc: "registers for focused keyboard events", desc: "registers for focused keyboard events",
text: "hello",
opts: []Option{ opts: []Option{
Key(keyboard.KeyEnter), Key(keyboard.KeyEnter),
}, },
want: widgetapi.Options{ want: widgetapi.Options{
MinimumSize: image.Point{6, 3}, MinimumSize: image.Point{8, 4},
MaximumSize: image.Point{8, 4},
WantKeyboard: widgetapi.KeyScopeFocused, WantKeyboard: widgetapi.KeyScopeFocused,
WantMouse: true, WantMouse: true,
}, },
}, },
{ {
desc: "registers for global keyboard events", desc: "registers for global keyboard events",
text: "hello",
opts: []Option{ opts: []Option{
GlobalKey(keyboard.KeyEnter), GlobalKey(keyboard.KeyEnter),
}, },
want: widgetapi.Options{ want: widgetapi.Options{
MinimumSize: image.Point{6, 3}, MinimumSize: image.Point{8, 4},
WantKeyboard: widgetapi.KeyScopeFocused, MaximumSize: image.Point{8, 4},
WantKeyboard: widgetapi.KeyScopeGlobal,
WantMouse: true, WantMouse: true,
}, },
}, },
@ -191,7 +258,7 @@ func TestOptions(t *testing.T) {
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.desc, func(t *testing.T) { t.Run(tc.desc, func(t *testing.T) {
ct := &callbackTracker{} ct := &callbackTracker{}
b, err := New("text", ct.callback, tc.opts...) b, err := New(tc.text, ct.callback, tc.opts...)
if err != nil { if err != nil {
t.Fatalf("New => unexpected error: %v", err) t.Fatalf("New => unexpected error: %v", err)
} }

View File

@ -17,13 +17,17 @@ package main
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/mum4k/termdash" "github.com/mum4k/termdash"
"github.com/mum4k/termdash/align"
"github.com/mum4k/termdash/container" "github.com/mum4k/termdash/container"
"github.com/mum4k/termdash/draw" "github.com/mum4k/termdash/draw"
"github.com/mum4k/termdash/terminal/termbox" "github.com/mum4k/termdash/terminal/termbox"
"github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/terminalapi"
"github.com/mum4k/termdash/widgets/button"
"github.com/mum4k/termdash/widgets/segmentdisplay"
) )
func main() { func main() {
@ -35,10 +39,64 @@ func main() {
ctx, cancel := context.WithCancel(context.Background()) 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( c, err := container.New(
t, t,
container.Border(draw.LineStyleLight), container.Border(draw.LineStyleLight),
container.BorderTitle("PRESS Q TO QUIT"), 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 { if err != nil {
panic(err) panic(err)

View File

@ -20,6 +20,7 @@ import (
"fmt" "fmt"
"github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/cell"
"github.com/mum4k/termdash/cell/runewidth"
"github.com/mum4k/termdash/keyboard" "github.com/mum4k/termdash/keyboard"
"github.com/mum4k/termdash/widgetapi" "github.com/mum4k/termdash/widgetapi"
) )
@ -61,43 +62,31 @@ func (o *options) validate() error {
} }
// newOptions returns options with the default values set. // newOptions returns options with the default values set.
func newOptions(textWidth int) *options { func newOptions(text string) *options {
return &options{ return &options{
fillColor: DefaultFillColor, fillColor: cell.ColorNumber(117),
textColor: DefaultTextColor, textColor: cell.ColorBlack,
shadowColor: DefaultShadowColor, shadowColor: cell.ColorNumber(240),
height: DefaultHeight, 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. // FillColor sets the fill color of the button.
// Defaults to DefaultFillColor.
func FillColor(c cell.Color) Option { func FillColor(c cell.Color) Option {
return option(func(opts *options) { return option(func(opts *options) {
opts.fillColor = c 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. // TextColor sets the color of the text label in the button.
// Defaults to DefaultTextColor.
func TextColor(c cell.Color) Option { func TextColor(c cell.Color) Option {
return option(func(opts *options) { return option(func(opts *options) {
opts.textColor = c 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. // ShadowColor sets the color of the shadow under the button.
// Defaults to DefaultShadowColor.
func ShadowColor(c cell.Color) Option { func ShadowColor(c cell.Color) Option {
return option(func(opts *options) { return option(func(opts *options) {
opts.shadowColor = c 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. // Key configures the keyboard key that presses the button.
// The widget responds to this key only if its container if focused. // The widget responds to this key only if its container if focused.
// When not provided, the widget ignores all keyboard events. // 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 { func GlobalKey(k keyboard.Key) Option {
return option(func(opts *options) { return option(func(opts *options) {
opts.key = k 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.
}