Skip to content

Commit

Permalink
Merge pull request #276 from mum4k/243-button-improvements
Browse files Browse the repository at this point in the history
Improving the button widget.
  • Loading branch information
mum4k committed Nov 28, 2020
2 parents 324fe3f + dc90197 commit 09baa7d
Show file tree
Hide file tree
Showing 5 changed files with 1,235 additions and 99 deletions.
21 changes: 20 additions & 1 deletion CHANGELOG.md
Expand Up @@ -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

Expand Down
178 changes: 149 additions & 29 deletions widgets/button/button.go
Expand Up @@ -18,14 +18,17 @@ package button

import (
"errors"
"fmt"
"image"
"strings"
"sync"
"time"

"github.com/mum4k/termdash/align"
"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"
Expand All @@ -45,14 +48,33 @@ 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.
//
// 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
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {
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
}

if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(fillColor)); err != nil {
return err
}
return b.drawText(cvs, meta, buttonAr)
}

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)
// 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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
}
}

0 comments on commit 09baa7d

Please sign in to comment.