mirror of
https://github.com/mum4k/termdash.git
synced 2025-04-25 13:48:50 +08:00
337 lines
9.4 KiB
Go
337 lines
9.4 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"
|
|
"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"
|
|
"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
|
|
|
|
// 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 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
|
|
// 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) {
|
|
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")
|
|
}
|
|
|
|
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,
|
|
givenTOpts: givenTOpts,
|
|
tOptsTracker: tOptsTracker,
|
|
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)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
buttonAr := image.Rect(0, 0, cvsAr.Dx()-sw, cvsAr.Dy()-sw)
|
|
if b.state == button.Down && !b.opts.disableShadow {
|
|
buttonAr = shadowAr
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if b.opts.keys[k.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 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
|
|
} else {
|
|
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 + b.shadowWidth() + 2*b.opts.textHorizontalPadding
|
|
height := b.opts.height + b.shadowWidth()
|
|
return widgetapi.Options{
|
|
MinimumSize: image.Point{width, height},
|
|
MaximumSize: image.Point{width, height},
|
|
WantKeyboard: b.opts.keyScope,
|
|
WantMouse: widgetapi.MouseScopeGlobal,
|
|
}
|
|
}
|