mirror of https://github.com/mum4k/termdash.git
227 lines
6.5 KiB
Go
227 lines
6.5 KiB
Go
// Copyright 2019 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 implements an interactive widget that can be pressed to
|
|
// activate.
|
|
package button
|
|
|
|
import (
|
|
"errors"
|
|
"image"
|
|
"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/button"
|
|
"github.com/mum4k/termdash/private/canvas"
|
|
"github.com/mum4k/termdash/private/draw"
|
|
"github.com/mum4k/termdash/terminal/terminalapi"
|
|
"github.com/mum4k/termdash/widgetapi"
|
|
)
|
|
|
|
// CallbackFn is the function called when the button is pressed.
|
|
// The callback function must be light-weight, ideally just storing a value and
|
|
// returning, since more button presses might occur.
|
|
//
|
|
// The callback function must be thread-safe as the mouse or keyboard events
|
|
// that press the button are processed in a separate goroutine.
|
|
//
|
|
// If the function returns an error, the widget will forward it back to the
|
|
// termdash infrastructure which causes a panic, unless the user provided a
|
|
// termdash.ErrorHandler.
|
|
type CallbackFn func() error
|
|
|
|
// 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
|
|
|
|
// mouseFSM tracks left mouse clicks.
|
|
mouseFSM *button.FSM
|
|
// state is the current state of the button.
|
|
state button.State
|
|
|
|
// keyTriggerTime is the last time the button was pressed using a keyboard
|
|
// key. It is nil if the button was triggered by a mouse event.
|
|
// Used to draw button presses on keyboard events, since termbox doesn't
|
|
// provide us with release events for keys.
|
|
keyTriggerTime *time.Time
|
|
|
|
// callback gets called on each button press.
|
|
callback CallbackFn
|
|
|
|
// mu protects the widget.
|
|
mu sync.Mutex
|
|
|
|
// opts are the provided options.
|
|
opts *options
|
|
}
|
|
|
|
// 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) {
|
|
if cFn == nil {
|
|
return nil, errors.New("the CallbackFn argument cannot be nil")
|
|
}
|
|
|
|
opt := newOptions(text)
|
|
for _, o := range opts {
|
|
o.set(opt)
|
|
}
|
|
if err := opt.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Button{
|
|
text: text,
|
|
mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR),
|
|
callback: cFn,
|
|
opts: opt,
|
|
}, nil
|
|
}
|
|
|
|
// Vars to be replaced from tests.
|
|
var (
|
|
// Runes to use in cells that contain the button.
|
|
// Changed from tests to provide readable test failures.
|
|
buttonRune = ' '
|
|
// Runes to use in cells that contain the shadow.
|
|
// Changed from tests to provide readable test failures.
|
|
shadowRune = ' '
|
|
|
|
// timeSince is a function that calculates duration since some time.
|
|
timeSince = time.Since
|
|
)
|
|
|
|
// Draw draws the Button widget onto the canvas.
|
|
// Implements widgetapi.Widget.Draw.
|
|
func (b *Button) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.keyTriggerTime != nil {
|
|
since := timeSince(*b.keyTriggerTime)
|
|
if since > b.opts.keyUpDelay {
|
|
b.state = button.Up
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil {
|
|
return err
|
|
}
|
|
|
|
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 != 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)),
|
|
)
|
|
}
|
|
|
|
// activated asserts whether the keyboard event activated the button.
|
|
func (b *Button) keyActivated(k *terminalapi.Keyboard) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if k.Key == b.opts.key {
|
|
b.state = button.Down
|
|
now := time.Now().UTC()
|
|
b.keyTriggerTime = &now
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Keyboard processes keyboard events, acts as a button press on the configured
|
|
// Key.
|
|
//
|
|
// Implements widgetapi.Widget.Keyboard.
|
|
func (b *Button) Keyboard(k *terminalapi.Keyboard) error {
|
|
if b.keyActivated(k) {
|
|
// Mutex must be released when calling the callback.
|
|
// Users might call container methods from the callback like the
|
|
// Container.Update, see #205.
|
|
return b.callback()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mouseActivated asserts whether the mouse event activated the button.
|
|
func (b *Button) mouseActivated(m *terminalapi.Mouse) bool {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
clicked, state := b.mouseFSM.Event(m)
|
|
b.state = state
|
|
b.keyTriggerTime = nil
|
|
|
|
return clicked
|
|
}
|
|
|
|
// Mouse processes mouse events, acts as a button press if both the press and
|
|
// the release happen inside the button.
|
|
//
|
|
// Implements widgetapi.Widget.Mouse.
|
|
func (b *Button) Mouse(m *terminalapi.Mouse) error {
|
|
if b.mouseActivated(m) {
|
|
// Mutex must be released when calling the callback.
|
|
// Users might call container methods from the callback like the
|
|
// Container.Update, see #205.
|
|
return b.callback()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// shadowWidth is the width of the shadow under the button in cell.
|
|
const shadowWidth = 1
|
|
|
|
// Options implements widgetapi.Widget.Options.
|
|
func (b *Button) Options() widgetapi.Options {
|
|
// No need to lock, as the height and width get fixed when New is called.
|
|
|
|
width := b.opts.width + shadowWidth
|
|
height := b.opts.height + shadowWidth
|
|
return widgetapi.Options{
|
|
MinimumSize: image.Point{width, height},
|
|
MaximumSize: image.Point{width, height},
|
|
WantKeyboard: b.opts.keyScope,
|
|
WantMouse: widgetapi.MouseScopeGlobal,
|
|
}
|
|
}
|