diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ac93c..0e6bd2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +#### Infrastructure changes - ability to configure keyboard keys that move focus to the next or the previous container. -- widgets can not request keyboard events exclusively when focused. +- widgets can now request keyboard events exclusively when focused. +- ability to configure keyboard keys that move focus to the next or the + previous container. +- `container` now allows users to configure keyboard keys that move focus to + the next or the previous container. + +#### Updates to the `button` widget +- the `button` widget allows users to specify multiple trigger keys. +- the `button` widget now supports different keys for the global and focused + scope. +- the `button` widget can now be drawn without the shadow or the press + animation. +- the `button` widget can now be drawn without horizontal padding around its + text. +- the `button` widget now allows specifying cell options for each cell of the + displayed text. Separate cell options can be specified for each of button's + main states (up, focused and up, down). +- the `button` widget allows specifying separate fill color values for each of + its main states (up, focused and up, down). ## [0.13.0] - 17-Nov-2020 diff --git a/widgets/button/button.go b/widgets/button/button.go index 441103c..eb55238 100644 --- a/widgets/button/button.go +++ b/widgets/button/button.go @@ -18,7 +18,9 @@ package button import ( "errors" + "fmt" "image" + "strings" "sync" "time" @@ -26,6 +28,7 @@ import ( "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/mouse" "github.com/mum4k/termdash/private/alignfor" + "github.com/mum4k/termdash/private/attrrange" "github.com/mum4k/termdash/private/button" "github.com/mum4k/termdash/private/canvas" "github.com/mum4k/termdash/private/draw" @@ -45,6 +48,20 @@ import ( // termdash.ErrorHandler. type CallbackFn func() error +// TextChunk is a part of or the full text displayed in the button. +type TextChunk struct { + text string + tOpts *textOptions +} + +// NewChunk creates a new text chunk. Each chunk of text can have its own cell options. +func NewChunk(text string, tOpts ...TextOption) *TextChunk { + return &TextChunk{ + text: text, + tOpts: newTextOptions(tOpts...), + } +} + // Button can be pressed using a mouse click or a configured keyboard key. // // Upon each press, the button invokes a callback provided by the user. @@ -52,7 +69,12 @@ type CallbackFn func() error // Implements widgetapi.Widget. This object is thread-safe. type Button struct { // text in the text label displayed in the button. - text string + text strings.Builder + + // givenTOpts are text options given for the button's of text. + givenTOpts []*textOptions + // tOptsTracker tracks the positions in a text to which the givenTOpts apply. + tOptsTracker *attrrange.Tracker // mouseFSM tracks left mouse clicks. mouseFSM *button.FSM @@ -78,22 +100,57 @@ 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) { + return NewFromChunks([]*TextChunk{NewChunk(text)}, cFn, opts...) +} + +// NewFromChunks is like New, but allows specifying write options for +// individual chunks of text displayed in the button. +func NewFromChunks(chunks []*TextChunk, cFn CallbackFn, opts ...Option) (*Button, error) { if cFn == nil { return nil, errors.New("the CallbackFn argument cannot be nil") } - opt := newOptions(text) + if len(chunks) == 0 { + return nil, errors.New("at least one text chunk must be specified") + } + + var ( + text strings.Builder + givenTOpts []*textOptions + ) + tOptsTracker := attrrange.NewTracker() + for i, tc := range chunks { + if tc.text == "" { + return nil, fmt.Errorf("text chunk[%d] is empty, all chunks must contains some text", i) + } + + pos := text.Len() + givenTOpts = append(givenTOpts, tc.tOpts) + tOptsIdx := len(givenTOpts) - 1 + if err := tOptsTracker.Add(pos, pos+len(tc.text), tOptsIdx); err != nil { + return nil, err + } + text.WriteString(tc.text) + } + + opt := newOptions(text.String()) for _, o := range opts { o.set(opt) } if err := opt.validate(); err != nil { return nil, err } + + for _, tOpts := range givenTOpts { + tOpts.setDefaultFgColor(opt.textColor) + } return &Button{ - text: text, - mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR), - callback: cFn, - opts: opt, + text: text, + givenTOpts: givenTOpts, + tOptsTracker: tOptsTracker, + mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR), + callback: cFn, + opts: opt, }, nil } @@ -126,40 +183,90 @@ func (b *Button) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error { cvsAr := cvs.Area() b.mouseFSM.UpdateArea(cvsAr) - shadowAr := image.Rect(shadowWidth, shadowWidth, cvsAr.Dx(), cvsAr.Dy()) - if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil { - return err + sw := b.shadowWidth() + shadowAr := image.Rect(sw, sw, cvsAr.Dx(), cvsAr.Dy()) + if !b.opts.disableShadow { + if err := cvs.SetAreaCells(shadowAr, shadowRune, 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 := image.Rect(0, 0, cvsAr.Dx()-sw, cvsAr.Dy()-sw) + if b.state == button.Down && !b.opts.disableShadow { buttonAr = shadowAr } - if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil { - return err + var fillColor cell.Color + switch { + case b.state == button.Down && b.opts.pressedFillColor != nil: + fillColor = *b.opts.pressedFillColor + case meta.Focused && b.opts.focusedFillColor != nil: + fillColor = *b.opts.focusedFillColor + default: + fillColor = b.opts.fillColor } - textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y) - start, err := alignfor.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle) + if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(fillColor)); err != nil { + return err + } + return b.drawText(cvs, meta, buttonAr) +} + +// drawText draws the text inside the button. +func (b *Button) drawText(cvs *canvas.Canvas, meta *widgetapi.Meta, buttonAr image.Rectangle) error { + pad := b.opts.textHorizontalPadding + textAr := image.Rect(buttonAr.Min.X+pad, buttonAr.Min.Y, buttonAr.Dx()-pad, buttonAr.Max.Y) + start, err := alignfor.Text(textAr, b.text.String(), align.HorizontalCenter, align.VerticalMiddle) if err != nil { return err } - return draw.Text(cvs, b.text, start, - draw.TextOverrunMode(draw.OverrunModeThreeDot), - draw.TextMaxX(buttonAr.Max.X), - draw.TextCellOpts(cell.FgColor(b.opts.textColor)), - ) + + maxCells := buttonAr.Max.X - start.X + trimmed, err := draw.TrimText(b.text.String(), maxCells, draw.OverrunModeThreeDot) + if err != nil { + return err + } + + optRange, err := b.tOptsTracker.ForPosition(0) // Text options for the current byte. + if err != nil { + return err + } + + cur := start + for i, r := range trimmed { + if i >= optRange.High { // Get the next write options. + or, err := b.tOptsTracker.ForPosition(i) + if err != nil { + return err + } + optRange = or + } + + tOpts := b.givenTOpts[optRange.AttrIdx] + var cellOpts []cell.Option + switch { + case b.state == button.Down && len(tOpts.pressedCellOpts) > 0: + cellOpts = tOpts.pressedCellOpts + case meta.Focused && len(tOpts.focusedCellOpts) > 0: + cellOpts = tOpts.focusedCellOpts + default: + cellOpts = tOpts.cellOpts + } + cells, err := cvs.SetCell(cur, r, cellOpts...) + if err != nil { + return err + } + cur = image.Point{cur.X + cells, cur.Y} + } + return nil } // activated asserts whether the keyboard event activated the button. -func (b *Button) keyActivated(k *terminalapi.Keyboard) bool { +func (b *Button) keyActivated(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) bool { b.mu.Lock() defer b.mu.Unlock() - if k.Key == b.opts.key { + if b.opts.globalKeys[k.Key] || (b.opts.focusedKeys[k.Key] && meta.Focused) { b.state = button.Down now := time.Now().UTC() b.keyTriggerTime = &now @@ -173,7 +280,7 @@ func (b *Button) keyActivated(k *terminalapi.Keyboard) bool { // // Implements widgetapi.Widget.Keyboard. func (b *Button) Keyboard(k *terminalapi.Keyboard, meta *widgetapi.EventMeta) error { - if b.keyActivated(k) { + if b.keyActivated(k, meta) { // Mutex must be released when calling the callback. // Users might call container methods from the callback like the // Container.Update, see #205. @@ -208,19 +315,32 @@ func (b *Button) Mouse(m *terminalapi.Mouse, meta *widgetapi.EventMeta) error { return nil } -// shadowWidth is the width of the shadow under the button in cell. -const shadowWidth = 1 +// shadowWidth returns the width of the shadow under the button or zero if the +// button shouldn't have any shadow. +func (b *Button) shadowWidth() int { + if b.opts.disableShadow { + return 0 + } + return 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. - width := b.opts.width + shadowWidth - height := b.opts.height + shadowWidth + width := b.opts.width + b.shadowWidth() + 2*b.opts.textHorizontalPadding + height := b.opts.height + b.shadowWidth() + + var keyScope widgetapi.KeyScope + if len(b.opts.focusedKeys) > 0 || len(b.opts.globalKeys) > 0 { + keyScope = widgetapi.KeyScopeGlobal + } else { + keyScope = widgetapi.KeyScopeNone + } return widgetapi.Options{ MinimumSize: image.Point{width, height}, MaximumSize: image.Point{width, height}, - WantKeyboard: b.opts.keyScope, + WantKeyboard: keyScope, WantMouse: widgetapi.MouseScopeGlobal, } } diff --git a/widgets/button/button_test.go b/widgets/button/button_test.go index d455f01..7ab9090 100644 --- a/widgets/button/button_test.go +++ b/widgets/button/button_test.go @@ -63,13 +63,23 @@ func (ct *callbackTracker) callback() error { return nil } +// event represents a terminal event for tests. +type event struct { + ev terminalapi.Event + meta *widgetapi.EventMeta +} + func TestButton(t *testing.T) { tests := []struct { - desc string - text string + desc string + + // Only one of these must be specified. + text string // Calls New() as the constructor. + textChunks []*TextChunk // Calls NewFromChunks() as the constructor. + callback *callbackTracker opts []Option - events []terminalapi.Event + events []*event canvas image.Rectangle meta *widgetapi.Meta @@ -86,6 +96,8 @@ func TestButton(t *testing.T) { { desc: "New fails with nil callback", canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, wantNewErr: true, }, { @@ -95,6 +107,8 @@ func TestButton(t *testing.T) { KeyUpDelay(-1 * time.Second), }, canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, wantNewErr: true, }, { @@ -104,6 +118,8 @@ func TestButton(t *testing.T) { Height(0), }, canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, wantNewErr: true, }, { @@ -113,6 +129,156 @@ func TestButton(t *testing.T) { Width(0), }, canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "New fails with negative textHorizontalPadding", + callback: &callbackTracker{}, + opts: []Option{ + TextHorizontalPadding(-1), + }, + canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "New fails when duplicate Key and GlobalKey are specified", + callback: &callbackTracker{}, + opts: []Option{ + Key('a'), + GlobalKey('a'), + }, + canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "New fails when duplicate Keys and GlobalKeys are specified", + callback: &callbackTracker{}, + opts: []Option{ + Keys('a'), + GlobalKeys('a'), + }, + canvas: image.Rect(0, 0, 1, 1), + text: "hello", + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with nil callback", + textChunks: []*TextChunk{ + NewChunk("text"), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with negative keyUpDelay", + textChunks: []*TextChunk{ + NewChunk("text"), + }, + callback: &callbackTracker{}, + opts: []Option{ + KeyUpDelay(-1 * time.Second), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with zero Height", + textChunks: []*TextChunk{ + NewChunk("text"), + }, + callback: &callbackTracker{}, + opts: []Option{ + Height(0), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with zero Width", + textChunks: []*TextChunk{ + NewChunk("text"), + }, + callback: &callbackTracker{}, + opts: []Option{ + Width(0), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with negative textHorizontalPadding", + textChunks: []*TextChunk{ + NewChunk("text"), + }, + callback: &callbackTracker{}, + opts: []Option{ + TextHorizontalPadding(-1), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails when duplicate Key and GlobalKey are specified", + callback: &callbackTracker{}, + opts: []Option{ + Key('a'), + GlobalKey('a'), + }, + canvas: image.Rect(0, 0, 1, 1), + textChunks: []*TextChunk{ + NewChunk("text"), + }, + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails when duplicate Keys and GlobalKeys are specified", + callback: &callbackTracker{}, + opts: []Option{ + Keys('a'), + GlobalKeys('a'), + }, + canvas: image.Rect(0, 0, 1, 1), + textChunks: []*TextChunk{ + NewChunk("text"), + }, + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with zero chunks", + textChunks: []*TextChunk{}, + callback: &callbackTracker{}, + opts: []Option{ + TextHorizontalPadding(-1), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, + wantNewErr: true, + }, + { + desc: "NewFromChunks fails with an empty chunk", + textChunks: []*TextChunk{ + NewChunk(""), + }, + callback: &callbackTracker{}, + opts: []Option{ + TextHorizontalPadding(-1), + }, + canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, wantNewErr: true, }, { @@ -120,6 +286,7 @@ func TestButton(t *testing.T) { callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 1, 1), + meta: &widgetapi.Meta{Focused: false}, wantDrawErr: true, }, { @@ -127,6 +294,7 @@ func TestButton(t *testing.T) { callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) cvs := testcanvas.MustNew(ft.Area()) @@ -149,13 +317,45 @@ func TestButton(t *testing.T) { }, wantCallback: &callbackTracker{}, }, + { + desc: "draws button without a shadow in up state", + callback: &callbackTracker{}, + opts: []Option{ + DisableShadow(), + }, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, { desc: "draws button in down state due to a mouse event", callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -176,14 +376,55 @@ func TestButton(t *testing.T) { }, wantCallback: &callbackTracker{}, }, + { + desc: "draws button in down state without a shadow", + callback: &callbackTracker{}, + opts: []Option{ + DisableShadow(), + }, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, { desc: "mouse triggered the callback", callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.EventMeta{}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -218,8 +459,164 @@ func TestButton(t *testing.T) { Key(keyboard.KeyEnter), }, canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "ignores keyboard event configured with Key when not focused", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: false}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: false, + count: 0, + }, + }, + + { + desc: "draws button in down state due to a keyboard event when multiple keys are specified", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Keys(keyboard.KeyEnter, keyboard.KeyTab), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyTab}, + meta: &widgetapi.EventMeta{Focused: true}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "draws button in down state due to a keyboard event when single global key is specified", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + GlobalKey(keyboard.KeyTab), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyTab}, + meta: &widgetapi.EventMeta{}, + }, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "draws button in down state due to a keyboard event when multiple global keys are specified", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + GlobalKeys(keyboard.KeyEnter, keyboard.KeyTab), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyTab}, + meta: &widgetapi.EventMeta{}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -248,8 +645,12 @@ func TestButton(t *testing.T) { callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -284,8 +685,12 @@ func TestButton(t *testing.T) { return 200 * time.Millisecond }, canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -321,8 +726,12 @@ func TestButton(t *testing.T) { return 200 * time.Millisecond }, canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -361,10 +770,20 @@ func TestButton(t *testing.T) { return 200 * time.Millisecond }, canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -396,11 +815,24 @@ func TestButton(t *testing.T) { callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.EventMeta{}, + }, + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.EventMeta{}, + }, }, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) @@ -434,9 +866,16 @@ func TestButton(t *testing.T) { }, text: "hello", canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, - &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + meta: &widgetapi.EventMeta{}, + }, }, wantCallbackErr: true, }, @@ -454,8 +893,12 @@ func TestButton(t *testing.T) { return 200 * time.Millisecond }, canvas: image.Rect(0, 0, 8, 4), - events: []terminalapi.Event{ - &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.Meta{Focused: false}, + events: []*event{ + { + ev: &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + meta: &widgetapi.EventMeta{Focused: true}, + }, }, wantCallbackErr: true, }, @@ -464,6 +907,7 @@ func TestButton(t *testing.T) { callback: &callbackTracker{}, text: "hello", canvas: image.Rect(0, 0, 8, 2), + meta: &widgetapi.Meta{Focused: false}, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) cvs := testcanvas.MustNew(ft.Area()) @@ -491,6 +935,7 @@ func TestButton(t *testing.T) { callback: &callbackTracker{}, text: "h", canvas: image.Rect(0, 0, 4, 2), + meta: &widgetapi.Meta{Focused: false}, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) cvs := testcanvas.MustNew(ft.Area()) @@ -521,6 +966,7 @@ func TestButton(t *testing.T) { TextColor(cell.ColorRed), }, canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) cvs := testcanvas.MustNew(ft.Area()) @@ -551,6 +997,7 @@ func TestButton(t *testing.T) { FillColor(cell.ColorRed), }, canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) cvs := testcanvas.MustNew(ft.Area()) @@ -581,6 +1028,7 @@ func TestButton(t *testing.T) { ShadowColor(cell.ColorRed), }, canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, want: func(size image.Point) *faketerm.Terminal { ft := faketerm.MustNew(size) cvs := testcanvas.MustNew(ft.Area()) @@ -603,6 +1051,312 @@ func TestButton(t *testing.T) { }, wantCallback: &callbackTracker{}, }, + { + desc: "draws button with text chunks and custom fill color in up state", + callback: &callbackTracker{}, + opts: []Option{ + FillColor(cell.ColorBlue), + FocusedFillColor(cell.ColorYellow), + PressedFillColor(cell.ColorRed), + DisableShadow(), + }, + textChunks: []*TextChunk{ + NewChunk( + "h", + TextCellOpts(cell.FgColor(cell.ColorBlack)), + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)), + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)), + ), + NewChunk( + "ello", + TextCellOpts(cell.FgColor(cell.ColorMagenta)), + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + ), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue)) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorBlue)), + ) + testdraw.MustText(cvs, "ello", image.Point{2, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + cell.BgColor(cell.ColorBlue)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button with text chunks and custom fill color in focused up state", + callback: &callbackTracker{}, + opts: []Option{ + FillColor(cell.ColorBlue), + FocusedFillColor(cell.ColorYellow), + PressedFillColor(cell.ColorRed), + DisableShadow(), + }, + textChunks: []*TextChunk{ + NewChunk( + "h", + TextCellOpts(cell.FgColor(cell.ColorBlack)), + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)), + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)), + ), + NewChunk( + "ello", + TextCellOpts(cell.FgColor(cell.ColorMagenta)), + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + ), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: true}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorYellow)) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorWhite), + cell.BgColor(cell.ColorYellow)), + ) + testdraw.MustText(cvs, "ello", image.Point{2, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + cell.BgColor(cell.ColorYellow)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button with text chunks in up state, focused colors default to regular colors", + callback: &callbackTracker{}, + opts: []Option{ + FillColor(cell.ColorBlue), + PressedFillColor(cell.ColorRed), + DisableShadow(), + }, + textChunks: []*TextChunk{ + NewChunk( + "h", + TextCellOpts(cell.FgColor(cell.ColorBlack)), + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)), + ), + NewChunk( + "ello", + TextCellOpts(cell.FgColor(cell.ColorMagenta)), + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + ), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: true}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue)) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorBlue)), + ) + testdraw.MustText(cvs, "ello", image.Point{2, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + cell.BgColor(cell.ColorBlue)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button with text chunks and custom fill color in down state", + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + }, + callback: &callbackTracker{}, + opts: []Option{ + FillColor(cell.ColorBlue), + FocusedFillColor(cell.ColorYellow), + PressedFillColor(cell.ColorRed), + DisableShadow(), + }, + textChunks: []*TextChunk{ + NewChunk( + "h", + TextCellOpts(cell.FgColor(cell.ColorBlack)), + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)), + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)), + ), + NewChunk( + "ello", + TextCellOpts(cell.FgColor(cell.ColorMagenta)), + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + ), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorRed)) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorGreen), + cell.BgColor(cell.ColorRed)), + ) + testdraw.MustText(cvs, "ello", image.Point{2, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + cell.BgColor(cell.ColorRed)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button with text chunks and custom fill color in down focused state (focus has no impact)", + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + }, + callback: &callbackTracker{}, + opts: []Option{ + FillColor(cell.ColorBlue), + FocusedFillColor(cell.ColorYellow), + PressedFillColor(cell.ColorRed), + DisableShadow(), + }, + textChunks: []*TextChunk{ + NewChunk( + "h", + TextCellOpts(cell.FgColor(cell.ColorBlack)), + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)), + PressedTextCellOpts(cell.FgColor(cell.ColorGreen)), + ), + NewChunk( + "ello", + TextCellOpts(cell.FgColor(cell.ColorMagenta)), + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + PressedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + ), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: true}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorRed)) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorGreen), + cell.BgColor(cell.ColorRed)), + ) + testdraw.MustText(cvs, "ello", image.Point{2, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + cell.BgColor(cell.ColorRed)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button with text chunks in down satte, pressed colors default to regular colors", + events: []*event{ + { + ev: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + meta: &widgetapi.EventMeta{}, + }, + }, + callback: &callbackTracker{}, + opts: []Option{ + FillColor(cell.ColorBlue), + FocusedFillColor(cell.ColorYellow), + DisableShadow(), + }, + textChunks: []*TextChunk{ + NewChunk( + "h", + TextCellOpts(cell.FgColor(cell.ColorBlack)), + FocusedTextCellOpts(cell.FgColor(cell.ColorWhite)), + ), + NewChunk( + "ello", + TextCellOpts(cell.FgColor(cell.ColorMagenta)), + FocusedTextCellOpts(cell.FgColor(cell.ColorMagenta)), + ), + }, + canvas: image.Rect(0, 0, 8, 4), + meta: &widgetapi.Meta{Focused: false}, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 8, 4), 'x', cell.BgColor(cell.ColorBlue)) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorBlue)), + ) + testdraw.MustText(cvs, "ello", image.Point{2, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorMagenta), + cell.BgColor(cell.ColorBlue)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, } buttonRune = 'x' @@ -622,12 +1376,30 @@ func TestButton(t *testing.T) { } else { cFn = gotCallback.callback } - b, err := New(tc.text, cFn, tc.opts...) - if (err != nil) != tc.wantNewErr { - t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) + + if tc.text != "" && tc.textChunks != nil { + t.Fatalf("cannot specify both text and textChunks in the testdata") } - if err != nil { - return + + var btn *Button + if tc.textChunks != nil { + b, err := NewFromChunks(tc.textChunks, cFn, tc.opts...) + if (err != nil) != tc.wantNewErr { + t.Errorf("NewFromChunks => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) + } + if err != nil { + return + } + btn = b + } else { + b, err := New(tc.text, cFn, tc.opts...) + if (err != nil) != tc.wantNewErr { + t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) + } + if err != nil { + return + } + btn = b } { @@ -636,7 +1408,7 @@ func TestButton(t *testing.T) { if err != nil { t.Fatalf("canvas.New => unexpected error: %v", err) } - err = b.Draw(c, tc.meta) + err = btn.Draw(c, tc.meta) if (err != nil) != tc.wantDrawErr { t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } @@ -645,10 +1417,10 @@ func TestButton(t *testing.T) { } } - for i, ev := range tc.events { - switch e := ev.(type) { + for i, event := range tc.events { + switch e := event.ev.(type) { case *terminalapi.Mouse: - err := b.Mouse(e, &widgetapi.EventMeta{}) + err := btn.Mouse(e, event.meta) // Only the last event in test cases is the one that triggers the callback. if i == len(tc.events)-1 { if (err != nil) != tc.wantCallbackErr { @@ -664,7 +1436,7 @@ func TestButton(t *testing.T) { } case *terminalapi.Keyboard: - err := b.Keyboard(e, &widgetapi.EventMeta{}) + err := btn.Keyboard(e, event.meta) // Only the last event in test cases is the one that triggers the callback. if i == len(tc.events)-1 { if (err != nil) != tc.wantCallbackErr { @@ -680,7 +1452,7 @@ func TestButton(t *testing.T) { } default: - t.Fatalf("unsupported event type: %T", ev) + t.Fatalf("unsupported event type: %T", event.ev) } } @@ -689,7 +1461,7 @@ func TestButton(t *testing.T) { t.Fatalf("canvas.New => unexpected error: %v", err) } - err = b.Draw(c, tc.meta) + err = btn.Draw(c, tc.meta) if (err != nil) != tc.wantDrawErr { t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) } @@ -778,11 +1550,25 @@ func TestOptions(t *testing.T) { }, }, { - desc: "custom width specified", + desc: "custom width specified with default padding", text: "hello", opts: []Option{ Width(10), }, + want: widgetapi.Options{ + MinimumSize: image.Point{13, 4}, + MaximumSize: image.Point{13, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "custom width specified with custom padding", + text: "hello", + opts: []Option{ + Width(10), + TextHorizontalPadding(0), + }, want: widgetapi.Options{ MinimumSize: image.Point{11, 4}, MaximumSize: image.Point{11, 4}, @@ -790,9 +1576,23 @@ func TestOptions(t *testing.T) { WantMouse: widgetapi.MouseScopeGlobal, }, }, - { - desc: "doesn't want keyboard by default", + desc: "without shadow or padding", + text: "hello", + opts: []Option{ + Width(10), + TextHorizontalPadding(0), + DisableShadow(), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{10, 3}, + MaximumSize: image.Point{10, 3}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "doesn't want keyboard by default without any keys", text: "hello", want: widgetapi.Options{ MinimumSize: image.Point{8, 4}, @@ -802,7 +1602,7 @@ func TestOptions(t *testing.T) { }, }, { - desc: "registers for focused keyboard events", + desc: "registers for keyboard events when Key used", text: "hello", opts: []Option{ Key(keyboard.KeyEnter), @@ -810,12 +1610,25 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{8, 4}, MaximumSize: image.Point{8, 4}, - WantKeyboard: widgetapi.KeyScopeFocused, + WantKeyboard: widgetapi.KeyScopeGlobal, WantMouse: widgetapi.MouseScopeGlobal, }, }, { - desc: "registers for global keyboard events", + desc: "registers for keyboard events when Keys used", + text: "hello", + opts: []Option{ + Keys(keyboard.KeyEnter, keyboard.KeyTab), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeGlobal, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "registers for keyboard events when GlobalKey used", text: "hello", opts: []Option{ GlobalKey(keyboard.KeyEnter), @@ -827,6 +1640,19 @@ func TestOptions(t *testing.T) { WantMouse: widgetapi.MouseScopeGlobal, }, }, + { + desc: "registers for keyboard events when GlobalKeys used", + text: "hello", + opts: []Option{ + GlobalKeys(keyboard.KeyEnter, keyboard.KeyTab), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeGlobal, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, } for _, tc := range tests { diff --git a/widgets/button/options.go b/widgets/button/options.go index ab0db04..030b8a2 100644 --- a/widgets/button/options.go +++ b/widgets/button/options.go @@ -42,18 +42,25 @@ func (o option) set(opts *options) { // options holds the provided options. type options struct { - fillColor cell.Color - textColor cell.Color - shadowColor cell.Color - height int - width int - key keyboard.Key - keyScope widgetapi.KeyScope - keyUpDelay time.Duration + fillColor cell.Color + focusedFillColor *cell.Color + pressedFillColor *cell.Color + textColor cell.Color + textHorizontalPadding int + shadowColor cell.Color + disableShadow bool + height int + width int + focusedKeys map[keyboard.Key]bool + globalKeys map[keyboard.Key]bool + keyUpDelay time.Duration } // validate validates the provided options. func (o *options) validate() error { + if min := 0; o.textHorizontalPadding < min { + return fmt.Errorf("invalid textHorizontalPadding %d, must be %d <= textHorizontalPadding", o.textHorizontalPadding, min) + } if min := 1; o.height < min { return fmt.Errorf("invalid height %d, must be %d <= height", o.height, min) } @@ -63,18 +70,33 @@ func (o *options) validate() error { if min := time.Duration(0); o.keyUpDelay < min { return fmt.Errorf("invalid keyUpDelay %v, must be %v <= keyUpDelay", o.keyUpDelay, min) } + + for k := range o.globalKeys { + if o.focusedKeys[k] { + return fmt.Errorf("key %q cannot be configured as both a focused key (options Key or Keys) and a global key (options GlobalKey or GlobalKeys)", k) + } + } return nil } +// keyScope stores a key and its scope. +type keyScope struct { + key keyboard.Key + scope widgetapi.KeyScope +} + // newOptions returns options with the default values set. func newOptions(text string) *options { return &options{ - fillColor: cell.ColorNumber(117), - textColor: cell.ColorBlack, - shadowColor: cell.ColorNumber(240), - height: DefaultHeight, - width: widthFor(text), - keyUpDelay: DefaultKeyUpDelay, + fillColor: cell.ColorNumber(117), + textColor: cell.ColorBlack, + textHorizontalPadding: DefaultTextHorizontalPadding, + shadowColor: cell.ColorNumber(240), + height: DefaultHeight, + width: widthFor(text), + keyUpDelay: DefaultKeyUpDelay, + focusedKeys: map[keyboard.Key]bool{}, + globalKeys: map[keyboard.Key]bool{}, } } @@ -85,6 +107,23 @@ func FillColor(c cell.Color) Option { }) } +// FocusedFillColor sets the fill color of the button when the widget's +// container is focused. +// Defaults to FillColor. +func FocusedFillColor(c cell.Color) Option { + return option(func(opts *options) { + opts.focusedFillColor = &c + }) +} + +// PressedFillColor sets the fill color of the button when it is pressed. +// Defaults to FillColor. +func PressedFillColor(c cell.Color) Option { + return option(func(opts *options) { + opts.pressedFillColor = &c + }) +} + // TextColor sets the color of the text label in the button. func TextColor(c cell.Color) Option { return option(func(opts *options) { @@ -114,6 +153,8 @@ func Height(cells int) Option { // Width sets the width of the button in cells. // Must be a positive non-zero integer. // Defaults to the auto-width based on the length of the text label. +// Not all the width may be available to the text if TextHorizontalPadding is +// set to a non-zero integer. func Width(cells int) Option { return option(func(opts *options) { opts.width = cells @@ -131,21 +172,47 @@ func WidthFor(text string) Option { // Key configures the keyboard key that presses the button. // The widget responds to this key only if its container is focused. -// When not provided, the widget ignores all keyboard events. +// +// Clears all keys set by Key() or Keys() previously. func Key(k keyboard.Key) Option { return option(func(opts *options) { - opts.key = k - opts.keyScope = widgetapi.KeyScopeFocused + opts.focusedKeys = map[keyboard.Key]bool{} + opts.focusedKeys[k] = true }) } // GlobalKey is like Key, but makes the widget respond to the key even if its // container isn't focused. -// When not provided, the widget ignores all keyboard events. +// +// Clears all keys set by GlobalKey() or GlobalKeys() previously. func GlobalKey(k keyboard.Key) Option { return option(func(opts *options) { - opts.key = k - opts.keyScope = widgetapi.KeyScopeGlobal + opts.globalKeys = map[keyboard.Key]bool{} + opts.globalKeys[k] = true + }) +} + +// Keys is like Key, but allows to configure multiple keys. +// +// Clears all keys set by Key() or Keys() previously. +func Keys(keys ...keyboard.Key) Option { + return option(func(opts *options) { + opts.focusedKeys = map[keyboard.Key]bool{} + for _, k := range keys { + opts.focusedKeys[k] = true + } + }) +} + +// GlobalKeys is like GlobalKey, but allows to configure multiple keys. +// +// Clears all keys set by GlobalKey() or GlobalKeys() previously. +func GlobalKeys(keys ...keyboard.Key) Option { + return option(func(opts *options) { + opts.globalKeys = map[keyboard.Key]bool{} + for _, k := range keys { + opts.globalKeys[k] = true + } }) } @@ -165,7 +232,26 @@ func KeyUpDelay(d time.Duration) Option { }) } +// DisableShadow when provided the button will not have a shadow area and will +// have no animation when pressed. +func DisableShadow() Option { + return option(func(opts *options) { + opts.disableShadow = true + }) +} + +// DefaultTextHorizontalPadding is the default value for the HorizontalPadding option. +const DefaultTextHorizontalPadding = 1 + +// TextHorizontalPadding sets padding on the left and right side of the +// button's text as the amount of cells. +func TextHorizontalPadding(p int) Option { + return option(func(opts *options) { + opts.textHorizontalPadding = p + }) +} + // 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. + return runewidth.StringWidth(text) } diff --git a/widgets/button/text_options.go b/widgets/button/text_options.go new file mode 100644 index 0000000..3311229 --- /dev/null +++ b/widgets/button/text_options.go @@ -0,0 +1,85 @@ +// Copyright 2020 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package button + +// text_options.go contains options used for the text displayed by the button. + +import "github.com/mum4k/termdash/cell" + +// TextOption is used to provide options to NewChunk(). +type TextOption interface { + // set sets the provided option. + set(*textOptions) +} + +// textOptions stores the provided options. +type textOptions struct { + cellOpts []cell.Option + focusedCellOpts []cell.Option + pressedCellOpts []cell.Option +} + +// setDefaultFgColor configures a default color for text if one isn't specified +// in the text options. +func (to *textOptions) setDefaultFgColor(c cell.Color) { + to.cellOpts = append( + []cell.Option{cell.FgColor(c)}, + to.cellOpts..., + ) +} + +// newTextOptions returns new textOptions instance. +func newTextOptions(tOpts ...TextOption) *textOptions { + to := &textOptions{} + for _, o := range tOpts { + o.set(to) + } + return to +} + +// textOption implements TextOption. +type textOption func(*textOptions) + +// set implements TextOption.set. +func (to textOption) set(tOpts *textOptions) { + to(tOpts) +} + +// TextCellOpts sets options on the cells that contain the button text. +// If not specified, all cells will just have their foreground color set to the +// value of TextColor(). +func TextCellOpts(opts ...cell.Option) TextOption { + return textOption(func(tOpts *textOptions) { + tOpts.cellOpts = opts + }) +} + +// FocusedTextCellOpts sets options on the cells that contain the button text +// when the widget's container is focused. +// If not specified, TextCellOpts will be used instead. +func FocusedTextCellOpts(opts ...cell.Option) TextOption { + return textOption(func(tOpts *textOptions) { + tOpts.focusedCellOpts = opts + }) +} + +// PressedTextCellOpts sets options on the cells that contain the button text +// when it is pressed. +// If not specified, TextCellOpts will be used instead. +func PressedTextCellOpts(opts ...cell.Option) TextOption { + return textOption(func(tOpts *textOptions) { + tOpts.pressedCellOpts = opts + }) +}