1
0
mirror of https://github.com/mum4k/termdash.git synced 2025-04-25 13:48:50 +08:00

Merge pull request #276 from mum4k/243-button-improvements

Improving the button widget.
This commit is contained in:
Jakub Sobon 2020-11-28 14:44:27 -05:00 committed by GitHub
commit 09baa7d379
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 1236 additions and 100 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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