mirror of
https://github.com/mum4k/termdash.git
synced 2025-04-25 13:48:50 +08:00
Merge pull request #189 from mum4k/text-input
Implementing text input field widget.
This commit is contained in:
commit
eacd178c7e
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- The `TextInput` widget, an input field allowing interactive text input.
|
||||
|
||||
### Changed
|
||||
|
||||
- Widgets now get information whether their container is focused when Draw is
|
||||
@ -26,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Termdash now requires at least Go version 1.10, which allows us to utilize
|
||||
`math.Round` instead of our own implementation and `strings.Builder` instead
|
||||
of `bytes.Buffer`.
|
||||
- Terminal shortcuts like `Ctrl-A` no longer come as two separate events,
|
||||
Termdash now mirrors termbox-go and sends these as one event.
|
||||
|
||||
## [0.8.0] - 30-Mar-2019
|
||||
|
||||
|
14
README.md
14
README.md
@ -10,7 +10,7 @@
|
||||
|
||||
Termdash is a cross-platform customizable terminal based dashboard.
|
||||
|
||||
[<img src="./doc/images/termdashdemo_0_8_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
|
||||
[<img src="./doc/images/termdashdemo_0_9_0.gif" alt="termdashdemo" type="image/gif">](termdashdemo/termdashdemo.go)
|
||||
|
||||
The feature set is inspired by the
|
||||
[gizak/termui](http://github.com/gizak/termui) project, which in turn was
|
||||
@ -86,6 +86,18 @@ go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go
|
||||
|
||||
[<img src="./doc/images/buttondemo.gif" alt="buttondemo" type="image/gif" width="50%">](widgets/button/buttondemo/buttondemo.go)
|
||||
|
||||
## The TextInput
|
||||
|
||||
Allows users to interact with the application by entering, editing and
|
||||
submitting text data. Run the
|
||||
[textinputdemo](widgets/textinput/textinputdemo/textinputdemo.go).
|
||||
|
||||
```go
|
||||
go run github.com/mum4k/termdash/widgets/textinput/textinputdemo/textinputdemo.go
|
||||
```
|
||||
|
||||
[<img src="./doc/images/textinputdemo.gif" alt="textinputdemo" type="image/gif" width="50%">](widgets/textinput/textinputdemo/textinputdemo.go)
|
||||
|
||||
## The Gauge
|
||||
|
||||
Displays the progress of an operation. Run the
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 8.2 MiB |
BIN
doc/images/termdashdemo_0_9_0.gif
Normal file
BIN
doc/images/termdashdemo_0_9_0.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.6 MiB |
BIN
doc/images/textinputdemo.gif
Normal file
BIN
doc/images/textinputdemo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.8 MiB |
@ -78,6 +78,29 @@ func VSplit(area image.Rectangle, widthPerc int) (left image.Rectangle, right im
|
||||
return left, right, nil
|
||||
}
|
||||
|
||||
// VSplitCells returns two new areas created by splitting the provided area
|
||||
// after the specified amount of cells of its width. The number of cells must
|
||||
// be a zero or a positive integer. Providing a zero returns left=image.ZR,
|
||||
// right=area. Providing a number equal or larger to area's width returns
|
||||
// left=area, right=image.ZR.
|
||||
func VSplitCells(area image.Rectangle, cells int) (left image.Rectangle, right image.Rectangle, err error) {
|
||||
if min := 0; cells < min {
|
||||
return image.ZR, image.ZR, fmt.Errorf("invalid cells %d, must be a positive integer", cells)
|
||||
}
|
||||
if cells == 0 {
|
||||
return image.ZR, area, nil
|
||||
}
|
||||
|
||||
width := area.Dx()
|
||||
if cells >= width {
|
||||
return area, image.ZR, nil
|
||||
}
|
||||
|
||||
left = image.Rect(area.Min.X, area.Min.Y, area.Min.X+cells, area.Max.Y)
|
||||
right = image.Rect(area.Min.X+cells, area.Min.Y, area.Max.X, area.Max.Y)
|
||||
return left, right, nil
|
||||
}
|
||||
|
||||
// ExcludeBorder returns a new area created by subtracting a border around the
|
||||
// provided area. Return the zero area if there isn't enough space to exclude
|
||||
// the border.
|
||||
|
@ -282,6 +282,91 @@ func TestVSplit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVSplitCells(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
area image.Rectangle
|
||||
cells int
|
||||
wantLeft image.Rectangle
|
||||
wantRight image.Rectangle
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
desc: "fails on negative cells",
|
||||
area: image.Rect(1, 1, 2, 2),
|
||||
cells: -1,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
desc: "returns area as left on cells too large",
|
||||
area: image.Rect(1, 1, 2, 2),
|
||||
cells: 2,
|
||||
wantLeft: image.Rect(1, 1, 2, 2),
|
||||
wantRight: image.ZR,
|
||||
},
|
||||
{
|
||||
desc: "returns area as left on cells equal area width",
|
||||
area: image.Rect(1, 1, 2, 2),
|
||||
cells: 1,
|
||||
wantLeft: image.Rect(1, 1, 2, 2),
|
||||
wantRight: image.ZR,
|
||||
},
|
||||
{
|
||||
desc: "returns area as right on zero cells",
|
||||
area: image.Rect(1, 1, 2, 2),
|
||||
cells: 0,
|
||||
wantRight: image.Rect(1, 1, 2, 2),
|
||||
wantLeft: image.ZR,
|
||||
},
|
||||
{
|
||||
desc: "zero area to begin with",
|
||||
area: image.ZR,
|
||||
cells: 0,
|
||||
wantLeft: image.ZR,
|
||||
wantRight: image.ZR,
|
||||
},
|
||||
{
|
||||
desc: "splits area with even width",
|
||||
area: image.Rect(1, 1, 3, 3),
|
||||
cells: 1,
|
||||
wantLeft: image.Rect(1, 1, 2, 3),
|
||||
wantRight: image.Rect(2, 1, 3, 3),
|
||||
},
|
||||
{
|
||||
desc: "splits area with odd width",
|
||||
area: image.Rect(1, 1, 4, 4),
|
||||
cells: 1,
|
||||
wantLeft: image.Rect(1, 1, 2, 4),
|
||||
wantRight: image.Rect(2, 1, 4, 4),
|
||||
},
|
||||
{
|
||||
desc: "splits to unequal areas",
|
||||
area: image.Rect(0, 0, 4, 4),
|
||||
cells: 3,
|
||||
wantLeft: image.Rect(0, 0, 3, 4),
|
||||
wantRight: image.Rect(3, 0, 4, 4),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
gotLeft, gotRight, err := VSplitCells(tc.area, tc.cells)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("VSplitCells => unexpected error:%v, wantErr:%v", err, tc.wantErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if diff := pretty.Compare(tc.wantLeft, gotLeft); diff != "" {
|
||||
t.Errorf("VSplitCells => left value unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
if diff := pretty.Compare(tc.wantRight, gotRight); diff != "" {
|
||||
t.Errorf("VSplitCells => right value unexpected diff (-want, +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludeBorder(t *testing.T) {
|
||||
tests := []struct {
|
||||
desc string
|
||||
|
@ -115,6 +115,11 @@ func (b Buffer) SetCell(p image.Point, r rune, opts ...cell.Option) (int, error)
|
||||
return -1, err
|
||||
}
|
||||
rw := runewidth.RuneWidth(r)
|
||||
if rw == 0 {
|
||||
// Even if the rune is invisible, like the zero-value rune, it still
|
||||
// occupies at least the target cell.
|
||||
rw = 1
|
||||
}
|
||||
if rw > remW {
|
||||
return -1, fmt.Errorf("cannot set rune %q of width %d at point %v, only have %d remaining cells at this line", r, rw, p, remW)
|
||||
}
|
||||
|
@ -411,6 +411,18 @@ func TestSetCell(t *testing.T) {
|
||||
return b
|
||||
}(),
|
||||
},
|
||||
{
|
||||
desc: "sets zero-value rune in a cell",
|
||||
buffer: mustNew(image.Point{3, 3}),
|
||||
point: image.Point{1, 2},
|
||||
r: 0,
|
||||
wantCells: 1,
|
||||
want: func() Buffer {
|
||||
b := mustNew(size)
|
||||
b[1][2].Rune = 0
|
||||
return b
|
||||
}(),
|
||||
},
|
||||
{
|
||||
desc: "sets cell options",
|
||||
buffer: mustNew(image.Point{3, 3}),
|
||||
|
@ -66,12 +66,12 @@ func TestRuneWidth(t *testing.T) {
|
||||
},
|
||||
{
|
||||
desc: "termdash special runes",
|
||||
runes: []rune{'⇄', '…', '⇧', '⇩'},
|
||||
runes: []rune{'⇄', '…', '⇧', '⇩', '⇦', '⇨'},
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
desc: "termdash special runes in eastAsian",
|
||||
runes: []rune{'⇄', '…', '⇧', '⇩'},
|
||||
runes: []rune{'⇄', '…', '⇧', '⇩', '⇦', '⇨'},
|
||||
eastAsian: true,
|
||||
want: 1,
|
||||
},
|
||||
|
@ -55,11 +55,40 @@ var buttonNames = map[Key]string{
|
||||
KeyArrowDown: "KeyArrowDown",
|
||||
KeyArrowLeft: "KeyArrowLeft",
|
||||
KeyArrowRight: "KeyArrowRight",
|
||||
KeyCtrlTilde: "KeyCtrlTilde",
|
||||
KeyCtrlA: "KeyCtrlA",
|
||||
KeyCtrlB: "KeyCtrlB",
|
||||
KeyCtrlC: "KeyCtrlC",
|
||||
KeyCtrlD: "KeyCtrlD",
|
||||
KeyCtrlE: "KeyCtrlE",
|
||||
KeyCtrlF: "KeyCtrlF",
|
||||
KeyCtrlG: "KeyCtrlG",
|
||||
KeyBackspace: "KeyBackspace",
|
||||
KeyTab: "KeyTab",
|
||||
KeyCtrlJ: "KeyCtrlJ",
|
||||
KeyCtrlK: "KeyCtrlK",
|
||||
KeyCtrlL: "KeyCtrlL",
|
||||
KeyEnter: "KeyEnter",
|
||||
KeyCtrlN: "KeyCtrlN",
|
||||
KeyCtrlO: "KeyCtrlO",
|
||||
KeyCtrlP: "KeyCtrlP",
|
||||
KeyCtrlQ: "KeyCtrlQ",
|
||||
KeyCtrlR: "KeyCtrlR",
|
||||
KeyCtrlS: "KeyCtrlS",
|
||||
KeyCtrlT: "KeyCtrlT",
|
||||
KeyCtrlU: "KeyCtrlU",
|
||||
KeyCtrlV: "KeyCtrlV",
|
||||
KeyCtrlW: "KeyCtrlW",
|
||||
KeyCtrlX: "KeyCtrlX",
|
||||
KeyCtrlY: "KeyCtrlY",
|
||||
KeyCtrlZ: "KeyCtrlZ",
|
||||
KeyEsc: "KeyEsc",
|
||||
KeyCtrl: "KeyCtrl",
|
||||
KeyCtrl4: "KeyCtrl4",
|
||||
KeyCtrl5: "KeyCtrl5",
|
||||
KeyCtrl6: "KeyCtrl6",
|
||||
KeyCtrl7: "KeyCtrl7",
|
||||
KeySpace: "KeySpace",
|
||||
KeyBackspace2: "KeyBackspace2",
|
||||
}
|
||||
|
||||
// Printable characters, but worth having constants for them.
|
||||
@ -91,9 +120,53 @@ const (
|
||||
KeyArrowDown
|
||||
KeyArrowLeft
|
||||
KeyArrowRight
|
||||
KeyCtrlTilde
|
||||
KeyCtrlA
|
||||
KeyCtrlB
|
||||
KeyCtrlC
|
||||
KeyCtrlD
|
||||
KeyCtrlE
|
||||
KeyCtrlF
|
||||
KeyCtrlG
|
||||
KeyBackspace
|
||||
KeyTab
|
||||
KeyCtrlJ
|
||||
KeyCtrlK
|
||||
KeyCtrlL
|
||||
KeyEnter
|
||||
KeyCtrlN
|
||||
KeyCtrlO
|
||||
KeyCtrlP
|
||||
KeyCtrlQ
|
||||
KeyCtrlR
|
||||
KeyCtrlS
|
||||
KeyCtrlT
|
||||
KeyCtrlU
|
||||
KeyCtrlV
|
||||
KeyCtrlW
|
||||
KeyCtrlX
|
||||
KeyCtrlY
|
||||
KeyCtrlZ
|
||||
KeyEsc
|
||||
KeyCtrl
|
||||
KeyCtrl4
|
||||
KeyCtrl5
|
||||
KeyCtrl6
|
||||
KeyCtrl7
|
||||
KeyBackspace2
|
||||
)
|
||||
|
||||
// Keys declared as duplicates by termbox.
|
||||
const (
|
||||
KeyCtrl2 Key = KeyCtrlTilde
|
||||
KeyCtrlSpace Key = KeyCtrlTilde
|
||||
KeyCtrlH Key = KeyBackspace
|
||||
KeyCtrlI Key = KeyTab
|
||||
KeyCtrlM Key = KeyEnter
|
||||
KeyCtrlLsqBracket Key = KeyEsc
|
||||
KeyCtrl3 Key = KeyEsc
|
||||
KeyCtrlBackslash Key = KeyCtrl4
|
||||
KeyCtrlRsqBracket Key = KeyCtrl5
|
||||
KeyCtrlSlash Key = KeyCtrl7
|
||||
KeyCtrlUnderscore Key = KeyCtrl7
|
||||
KeyCtrl8 Key = KeyBackspace2
|
||||
)
|
||||
|
@ -29,6 +29,7 @@ import (
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/container"
|
||||
"github.com/mum4k/termdash/container/grid"
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/linestyle"
|
||||
"github.com/mum4k/termdash/terminal/termbox"
|
||||
"github.com/mum4k/termdash/terminal/terminalapi"
|
||||
@ -40,6 +41,7 @@ import (
|
||||
"github.com/mum4k/termdash/widgets/segmentdisplay"
|
||||
"github.com/mum4k/termdash/widgets/sparkline"
|
||||
"github.com/mum4k/termdash/widgets/text"
|
||||
"github.com/mum4k/termdash/widgets/textinput"
|
||||
)
|
||||
|
||||
// redrawInterval is how often termdash redraws the screen.
|
||||
@ -48,6 +50,7 @@ const redrawInterval = 250 * time.Millisecond
|
||||
// widgets holds the widgets used by this demo.
|
||||
type widgets struct {
|
||||
segDist *segmentdisplay.SegmentDisplay
|
||||
input *textinput.TextInput
|
||||
rollT *text.Text
|
||||
spGreen *sparkline.SparkLine
|
||||
spRed *sparkline.SparkLine
|
||||
@ -64,10 +67,17 @@ type widgets struct {
|
||||
|
||||
// newWidgets creates all widgets used by this demo.
|
||||
func newWidgets(ctx context.Context, c *container.Container) (*widgets, error) {
|
||||
sd, err := newSegmentDisplay(ctx)
|
||||
updateText := make(chan string)
|
||||
sd, err := newSegmentDisplay(ctx, updateText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
input, err := newTextInput(updateText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rollT, err := newRollText(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -102,6 +112,7 @@ func newWidgets(ctx context.Context, c *container.Container) (*widgets, error) {
|
||||
}
|
||||
return &widgets{
|
||||
segDist: sd,
|
||||
input: input,
|
||||
rollT: rollT,
|
||||
spGreen: spGreen,
|
||||
spRed: spRed,
|
||||
@ -138,9 +149,13 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
|
||||
grid.RowHeightPerc(25,
|
||||
grid.Widget(w.segDist,
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("Press Q to quit"),
|
||||
container.BorderTitle("Press Esc to quit"),
|
||||
),
|
||||
),
|
||||
grid.RowHeightPerc(5,
|
||||
grid.Widget(w.input),
|
||||
),
|
||||
|
||||
grid.RowHeightPerc(5,
|
||||
grid.ColWidthPerc(25,
|
||||
grid.Widget(w.buttons.allB),
|
||||
@ -159,7 +174,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
|
||||
switch lt {
|
||||
case layoutAll:
|
||||
leftRows = append(leftRows,
|
||||
grid.RowHeightPerc(23,
|
||||
grid.RowHeightPerc(20,
|
||||
grid.ColWidthPerc(50,
|
||||
grid.Widget(w.rollT,
|
||||
container.Border(linestyle.Light),
|
||||
@ -188,7 +203,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
|
||||
container.BorderColor(cell.ColorNumber(39)),
|
||||
),
|
||||
),
|
||||
grid.RowHeightPerc(35,
|
||||
grid.RowHeightPerc(38,
|
||||
grid.Widget(w.heartLC,
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("A LineChart"),
|
||||
@ -315,40 +330,49 @@ func contLayout(w *widgets) ([]container.Option, error) {
|
||||
),
|
||||
}
|
||||
|
||||
segmentTextSpark := []container.Option{
|
||||
textAndSparks := []container.Option{
|
||||
container.SplitVertical(
|
||||
container.Left(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("A rolling text"),
|
||||
container.PlaceWidget(w.rollT),
|
||||
),
|
||||
container.Right(
|
||||
container.SplitHorizontal(
|
||||
container.Top(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("Green SparkLine"),
|
||||
container.PlaceWidget(w.spGreen),
|
||||
),
|
||||
container.Bottom(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("Red SparkLine"),
|
||||
container.PlaceWidget(w.spRed),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
segmentTextInputSparks := []container.Option{
|
||||
container.SplitHorizontal(
|
||||
container.Top(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("Press Q to quit"),
|
||||
container.BorderTitle("Press Esc to quit"),
|
||||
container.PlaceWidget(w.segDist),
|
||||
),
|
||||
container.Bottom(
|
||||
container.SplitHorizontal(
|
||||
container.Top(buttonRow...),
|
||||
container.Bottom(
|
||||
container.SplitVertical(
|
||||
container.Left(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("A rolling text"),
|
||||
container.PlaceWidget(w.rollT),
|
||||
),
|
||||
container.Right(
|
||||
container.SplitHorizontal(
|
||||
container.Top(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("Green SparkLine"),
|
||||
container.PlaceWidget(w.spGreen),
|
||||
),
|
||||
container.Bottom(
|
||||
container.Border(linestyle.Light),
|
||||
container.BorderTitle("Red SparkLine"),
|
||||
container.PlaceWidget(w.spRed),
|
||||
),
|
||||
),
|
||||
container.Top(
|
||||
container.SplitHorizontal(
|
||||
container.Top(
|
||||
container.PlaceWidget(w.input),
|
||||
),
|
||||
container.Bottom(buttonRow...),
|
||||
),
|
||||
),
|
||||
container.SplitPercent(20),
|
||||
container.Bottom(textAndSparks...),
|
||||
container.SplitPercent(40),
|
||||
),
|
||||
),
|
||||
container.SplitPercent(50),
|
||||
@ -374,7 +398,7 @@ func contLayout(w *widgets) ([]container.Option, error) {
|
||||
|
||||
leftSide := []container.Option{
|
||||
container.SplitHorizontal(
|
||||
container.Top(segmentTextSpark...),
|
||||
container.Top(segmentTextInputSparks...),
|
||||
container.Bottom(gaugeAndHeartbeat...),
|
||||
container.SplitPercent(50),
|
||||
),
|
||||
@ -475,7 +499,7 @@ func main() {
|
||||
}
|
||||
|
||||
quitter := func(k *terminalapi.Keyboard) {
|
||||
if k.Key == 'q' || k.Key == 'Q' {
|
||||
if k.Key == keyboard.KeyEsc || k.Key == keyboard.KeyCtrlC {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@ -501,44 +525,99 @@ func periodic(ctx context.Context, interval time.Duration, fn func() error) {
|
||||
}
|
||||
}
|
||||
|
||||
// newSegmentDisplay creates a new SegmentDisplay that shows the Termdash name.
|
||||
func newSegmentDisplay(ctx context.Context) (*segmentdisplay.SegmentDisplay, error) {
|
||||
// textState creates a rotated state for the text we are displaying.
|
||||
func textState(text string, capacity, step int) []rune {
|
||||
if capacity == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var state []rune
|
||||
for i := 0; i < capacity; i++ {
|
||||
state = append(state, ' ')
|
||||
}
|
||||
state = append(state, []rune(text)...)
|
||||
step = step % len(state)
|
||||
return rotateRunes(state, step)
|
||||
}
|
||||
|
||||
// newTextInput creates a new TextInput field that changes the text on the
|
||||
// SegmentDisplay.
|
||||
func newTextInput(updateText chan<- string) (*textinput.TextInput, error) {
|
||||
input, err := textinput.New(
|
||||
textinput.Label("Change text to: ", cell.FgColor(cell.ColorBlue)),
|
||||
textinput.MaxWidthCells(20),
|
||||
textinput.PlaceHolder("enter any text"),
|
||||
textinput.OnSubmit(func(text string) error {
|
||||
updateText <- text
|
||||
return nil
|
||||
}),
|
||||
textinput.ClearOnSubmit(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, err
|
||||
}
|
||||
|
||||
// newSegmentDisplay creates a new SegmentDisplay that initially shows the
|
||||
// Termdash name. Shows any text that is sent over the channel.
|
||||
func newSegmentDisplay(ctx context.Context, updateText <-chan string) (*segmentdisplay.SegmentDisplay, error) {
|
||||
sd, err := segmentdisplay.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
const text = "Termdash"
|
||||
colors := map[rune]cell.Color{
|
||||
'T': cell.ColorBlue,
|
||||
'e': cell.ColorRed,
|
||||
'r': cell.ColorYellow,
|
||||
'm': cell.ColorBlue,
|
||||
'd': cell.ColorGreen,
|
||||
'a': cell.ColorRed,
|
||||
's': cell.ColorGreen,
|
||||
'h': cell.ColorRed,
|
||||
colors := []cell.Color{
|
||||
cell.ColorBlue,
|
||||
cell.ColorRed,
|
||||
cell.ColorYellow,
|
||||
cell.ColorBlue,
|
||||
cell.ColorGreen,
|
||||
cell.ColorRed,
|
||||
cell.ColorGreen,
|
||||
cell.ColorRed,
|
||||
}
|
||||
|
||||
var state []rune
|
||||
for i := 0; i < len(text); i++ {
|
||||
state = append(state, ' ')
|
||||
}
|
||||
state = append(state, []rune(text)...)
|
||||
go periodic(ctx, 500*time.Millisecond, func() error {
|
||||
var chunks []*segmentdisplay.TextChunk
|
||||
for i := 0; i < len(text); i++ {
|
||||
chunks = append(chunks, segmentdisplay.NewChunk(
|
||||
string(state[i]),
|
||||
segmentdisplay.WriteCellOpts(cell.FgColor(colors[state[i]])),
|
||||
))
|
||||
text := "Termdash"
|
||||
step := 0
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
state := textState(text, sd.Capacity(), step)
|
||||
var chunks []*segmentdisplay.TextChunk
|
||||
for i := 0; i < sd.Capacity(); i++ {
|
||||
if i >= len(state) {
|
||||
break
|
||||
}
|
||||
|
||||
color := colors[i%len(colors)]
|
||||
chunks = append(chunks, segmentdisplay.NewChunk(
|
||||
string(state[i]),
|
||||
segmentdisplay.WriteCellOpts(cell.FgColor(color)),
|
||||
))
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := sd.Write(chunks); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
step++
|
||||
|
||||
case t := <-updateText:
|
||||
text = t
|
||||
sd.Reset()
|
||||
step = 0
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := sd.Write(chunks); err != nil {
|
||||
return err
|
||||
}
|
||||
state = rotateRunes(state, 1)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
return sd, nil
|
||||
}
|
||||
|
||||
|
@ -25,146 +25,84 @@ import (
|
||||
tbx "github.com/nsf/termbox-go"
|
||||
)
|
||||
|
||||
// newKeyboard creates a new termdash keyboard events with the provided keys.
|
||||
func newKeyboard(keys ...keyboard.Key) []terminalapi.Event {
|
||||
var evs []terminalapi.Event
|
||||
for _, k := range keys {
|
||||
evs = append(evs, &terminalapi.Keyboard{Key: k})
|
||||
}
|
||||
return evs
|
||||
// tbxToTd maps termbox key values to the termdash format.
|
||||
var tbxToTd = map[tbx.Key]keyboard.Key{
|
||||
tbx.KeySpace: keyboard.KeySpace,
|
||||
tbx.KeyF1: keyboard.KeyF1,
|
||||
tbx.KeyF2: keyboard.KeyF2,
|
||||
tbx.KeyF3: keyboard.KeyF3,
|
||||
tbx.KeyF4: keyboard.KeyF4,
|
||||
tbx.KeyF5: keyboard.KeyF5,
|
||||
tbx.KeyF6: keyboard.KeyF6,
|
||||
tbx.KeyF7: keyboard.KeyF7,
|
||||
tbx.KeyF8: keyboard.KeyF8,
|
||||
tbx.KeyF9: keyboard.KeyF9,
|
||||
tbx.KeyF10: keyboard.KeyF10,
|
||||
tbx.KeyF11: keyboard.KeyF11,
|
||||
tbx.KeyF12: keyboard.KeyF12,
|
||||
tbx.KeyInsert: keyboard.KeyInsert,
|
||||
tbx.KeyDelete: keyboard.KeyDelete,
|
||||
tbx.KeyHome: keyboard.KeyHome,
|
||||
tbx.KeyEnd: keyboard.KeyEnd,
|
||||
tbx.KeyPgup: keyboard.KeyPgUp,
|
||||
tbx.KeyPgdn: keyboard.KeyPgDn,
|
||||
tbx.KeyArrowUp: keyboard.KeyArrowUp,
|
||||
tbx.KeyArrowDown: keyboard.KeyArrowDown,
|
||||
tbx.KeyArrowLeft: keyboard.KeyArrowLeft,
|
||||
tbx.KeyArrowRight: keyboard.KeyArrowRight,
|
||||
tbx.KeyCtrlTilde: keyboard.KeyCtrlTilde,
|
||||
tbx.KeyCtrlA: keyboard.KeyCtrlA,
|
||||
tbx.KeyCtrlB: keyboard.KeyCtrlB,
|
||||
tbx.KeyCtrlC: keyboard.KeyCtrlC,
|
||||
tbx.KeyCtrlD: keyboard.KeyCtrlD,
|
||||
tbx.KeyCtrlE: keyboard.KeyCtrlE,
|
||||
tbx.KeyCtrlF: keyboard.KeyCtrlF,
|
||||
tbx.KeyCtrlG: keyboard.KeyCtrlG,
|
||||
tbx.KeyBackspace: keyboard.KeyBackspace,
|
||||
tbx.KeyTab: keyboard.KeyTab,
|
||||
tbx.KeyCtrlJ: keyboard.KeyCtrlJ,
|
||||
tbx.KeyCtrlK: keyboard.KeyCtrlK,
|
||||
tbx.KeyCtrlL: keyboard.KeyCtrlL,
|
||||
tbx.KeyEnter: keyboard.KeyEnter,
|
||||
tbx.KeyCtrlN: keyboard.KeyCtrlN,
|
||||
tbx.KeyCtrlO: keyboard.KeyCtrlO,
|
||||
tbx.KeyCtrlP: keyboard.KeyCtrlP,
|
||||
tbx.KeyCtrlQ: keyboard.KeyCtrlQ,
|
||||
tbx.KeyCtrlR: keyboard.KeyCtrlR,
|
||||
tbx.KeyCtrlS: keyboard.KeyCtrlS,
|
||||
tbx.KeyCtrlT: keyboard.KeyCtrlT,
|
||||
tbx.KeyCtrlU: keyboard.KeyCtrlU,
|
||||
tbx.KeyCtrlV: keyboard.KeyCtrlV,
|
||||
tbx.KeyCtrlW: keyboard.KeyCtrlW,
|
||||
tbx.KeyCtrlX: keyboard.KeyCtrlX,
|
||||
tbx.KeyCtrlY: keyboard.KeyCtrlY,
|
||||
tbx.KeyCtrlZ: keyboard.KeyCtrlZ,
|
||||
tbx.KeyEsc: keyboard.KeyEsc,
|
||||
tbx.KeyCtrl4: keyboard.KeyCtrl4,
|
||||
tbx.KeyCtrl5: keyboard.KeyCtrl5,
|
||||
tbx.KeyCtrl6: keyboard.KeyCtrl6,
|
||||
tbx.KeyCtrl7: keyboard.KeyCtrl7,
|
||||
tbx.KeyBackspace2: keyboard.KeyBackspace2,
|
||||
}
|
||||
|
||||
// convKey converts a termbox keyboard event to the termdash format.
|
||||
func convKey(tbxEv tbx.Event) []terminalapi.Event {
|
||||
func convKey(tbxEv tbx.Event) terminalapi.Event {
|
||||
if tbxEv.Key != 0 && tbxEv.Ch != 0 {
|
||||
return []terminalapi.Event{
|
||||
terminalapi.NewErrorf("the key event contain both a key(%v) and a character(%v)", tbxEv.Key, tbxEv.Ch),
|
||||
}
|
||||
return terminalapi.NewErrorf("the key event contain both a key(%v) and a character(%v)", tbxEv.Key, tbxEv.Ch)
|
||||
}
|
||||
|
||||
if tbxEv.Ch != 0 {
|
||||
return []terminalapi.Event{&terminalapi.Keyboard{
|
||||
return &terminalapi.Keyboard{
|
||||
Key: keyboard.Key(tbxEv.Ch),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
switch k := tbxEv.Key; k {
|
||||
case tbx.KeySpace:
|
||||
return newKeyboard(keyboard.KeySpace)
|
||||
case tbx.KeyF1:
|
||||
return newKeyboard(keyboard.KeyF1)
|
||||
case tbx.KeyF2:
|
||||
return newKeyboard(keyboard.KeyF2)
|
||||
case tbx.KeyF3:
|
||||
return newKeyboard(keyboard.KeyF3)
|
||||
case tbx.KeyF4:
|
||||
return newKeyboard(keyboard.KeyF4)
|
||||
case tbx.KeyF5:
|
||||
return newKeyboard(keyboard.KeyF5)
|
||||
case tbx.KeyF6:
|
||||
return newKeyboard(keyboard.KeyF6)
|
||||
case tbx.KeyF7:
|
||||
return newKeyboard(keyboard.KeyF7)
|
||||
case tbx.KeyF8:
|
||||
return newKeyboard(keyboard.KeyF8)
|
||||
case tbx.KeyF9:
|
||||
return newKeyboard(keyboard.KeyF9)
|
||||
case tbx.KeyF10:
|
||||
return newKeyboard(keyboard.KeyF10)
|
||||
case tbx.KeyF11:
|
||||
return newKeyboard(keyboard.KeyF11)
|
||||
case tbx.KeyF12:
|
||||
return newKeyboard(keyboard.KeyF12)
|
||||
case tbx.KeyInsert:
|
||||
return newKeyboard(keyboard.KeyInsert)
|
||||
case tbx.KeyDelete:
|
||||
return newKeyboard(keyboard.KeyDelete)
|
||||
case tbx.KeyHome:
|
||||
return newKeyboard(keyboard.KeyHome)
|
||||
case tbx.KeyEnd:
|
||||
return newKeyboard(keyboard.KeyEnd)
|
||||
case tbx.KeyPgup:
|
||||
return newKeyboard(keyboard.KeyPgUp)
|
||||
case tbx.KeyPgdn:
|
||||
return newKeyboard(keyboard.KeyPgDn)
|
||||
case tbx.KeyArrowUp:
|
||||
return newKeyboard(keyboard.KeyArrowUp)
|
||||
case tbx.KeyArrowDown:
|
||||
return newKeyboard(keyboard.KeyArrowDown)
|
||||
case tbx.KeyArrowLeft:
|
||||
return newKeyboard(keyboard.KeyArrowLeft)
|
||||
case tbx.KeyArrowRight:
|
||||
return newKeyboard(keyboard.KeyArrowRight)
|
||||
case tbx.KeyBackspace /*, tbx.KeyCtrlH */ :
|
||||
return newKeyboard(keyboard.KeyBackspace)
|
||||
case tbx.KeyTab /*, tbx.KeyCtrlI */ :
|
||||
return newKeyboard(keyboard.KeyTab)
|
||||
case tbx.KeyEnter /*, tbx.KeyCtrlM*/ :
|
||||
return newKeyboard(keyboard.KeyEnter)
|
||||
case tbx.KeyEsc /*, tbx.KeyCtrlLsqBracket, tbx.KeyCtrl3 */ :
|
||||
return newKeyboard(keyboard.KeyEsc)
|
||||
case tbx.KeyCtrl2 /*, tbx.KeyCtrlTilde, tbx.KeyCtrlSpace */ :
|
||||
return newKeyboard(keyboard.KeyCtrl, '2')
|
||||
case tbx.KeyCtrl4 /*, tbx.KeyCtrlBackslash */ :
|
||||
return newKeyboard(keyboard.KeyCtrl, '4')
|
||||
case tbx.KeyCtrl5 /*, tbx.KeyCtrlRsqBracket */ :
|
||||
return newKeyboard(keyboard.KeyCtrl, '5')
|
||||
case tbx.KeyCtrl6:
|
||||
return newKeyboard(keyboard.KeyCtrl, '6')
|
||||
case tbx.KeyCtrl7 /*, tbx.KeyCtrlSlash, tbx.KeyCtrlUnderscore */ :
|
||||
return newKeyboard(keyboard.KeyCtrl, '7')
|
||||
case tbx.KeyCtrl8:
|
||||
return newKeyboard(keyboard.KeyCtrl, '8')
|
||||
case tbx.KeyCtrlA:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'a')
|
||||
case tbx.KeyCtrlB:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'b')
|
||||
case tbx.KeyCtrlC:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'c')
|
||||
case tbx.KeyCtrlD:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'd')
|
||||
case tbx.KeyCtrlE:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'e')
|
||||
case tbx.KeyCtrlF:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'f')
|
||||
case tbx.KeyCtrlG:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'g')
|
||||
case tbx.KeyCtrlJ:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'j')
|
||||
case tbx.KeyCtrlK:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'k')
|
||||
case tbx.KeyCtrlL:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'l')
|
||||
case tbx.KeyCtrlN:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'n')
|
||||
case tbx.KeyCtrlO:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'o')
|
||||
case tbx.KeyCtrlP:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'p')
|
||||
case tbx.KeyCtrlQ:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'q')
|
||||
case tbx.KeyCtrlR:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'r')
|
||||
case tbx.KeyCtrlS:
|
||||
return newKeyboard(keyboard.KeyCtrl, 's')
|
||||
case tbx.KeyCtrlT:
|
||||
return newKeyboard(keyboard.KeyCtrl, 't')
|
||||
case tbx.KeyCtrlU:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'u')
|
||||
case tbx.KeyCtrlV:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'v')
|
||||
case tbx.KeyCtrlW:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'w')
|
||||
case tbx.KeyCtrlX:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'x')
|
||||
case tbx.KeyCtrlY:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'y')
|
||||
case tbx.KeyCtrlZ:
|
||||
return newKeyboard(keyboard.KeyCtrl, 'z')
|
||||
default:
|
||||
return []terminalapi.Event{
|
||||
terminalapi.NewErrorf("unknown keyboard key %v in a keyboard event", k),
|
||||
}
|
||||
k, ok := tbxToTd[tbxEv.Key]
|
||||
if !ok {
|
||||
return terminalapi.NewErrorf("unknown keyboard key '%v' in a keyboard event", k)
|
||||
}
|
||||
return &terminalapi.Keyboard{
|
||||
Key: k,
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,7 +168,9 @@ func toTermdashEvents(tbxEv tbx.Event) []terminalapi.Event {
|
||||
case tbx.EventMouse:
|
||||
return []terminalapi.Event{convMouse(tbxEv)}
|
||||
case tbx.EventKey:
|
||||
return convKey(tbxEv)
|
||||
return []terminalapi.Event{
|
||||
convKey(tbxEv),
|
||||
}
|
||||
default:
|
||||
return []terminalapi.Event{
|
||||
terminalapi.NewErrorf("unknown termbox event type: %v", t),
|
||||
|
@ -193,86 +193,99 @@ func TestKeyboardKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
key tbx.Key
|
||||
ch rune
|
||||
want []keyboard.Key
|
||||
want keyboard.Key
|
||||
wantErr bool
|
||||
}{
|
||||
{key: tbx.KeyF1, ch: 'a', wantErr: true},
|
||||
{key: 2000, wantErr: true},
|
||||
{ch: 'a', want: []keyboard.Key{'a'}},
|
||||
{ch: 'A', want: []keyboard.Key{'A'}},
|
||||
{ch: 'z', want: []keyboard.Key{'z'}},
|
||||
{ch: 'Z', want: []keyboard.Key{'Z'}},
|
||||
{ch: '0', want: []keyboard.Key{'0'}},
|
||||
{ch: '9', want: []keyboard.Key{'9'}},
|
||||
{ch: '!', want: []keyboard.Key{'!'}},
|
||||
{ch: ')', want: []keyboard.Key{')'}},
|
||||
{key: tbx.KeySpace, want: []keyboard.Key{keyboard.KeySpace}},
|
||||
{key: tbx.KeyF1, want: []keyboard.Key{keyboard.KeyF1}},
|
||||
{key: tbx.KeyF2, want: []keyboard.Key{keyboard.KeyF2}},
|
||||
{key: tbx.KeyF3, want: []keyboard.Key{keyboard.KeyF3}},
|
||||
{key: tbx.KeyF4, want: []keyboard.Key{keyboard.KeyF4}},
|
||||
{key: tbx.KeyF5, want: []keyboard.Key{keyboard.KeyF5}},
|
||||
{key: tbx.KeyF6, want: []keyboard.Key{keyboard.KeyF6}},
|
||||
{key: tbx.KeyF7, want: []keyboard.Key{keyboard.KeyF7}},
|
||||
{key: tbx.KeyF8, want: []keyboard.Key{keyboard.KeyF8}},
|
||||
{key: tbx.KeyF9, want: []keyboard.Key{keyboard.KeyF9}},
|
||||
{key: tbx.KeyF10, want: []keyboard.Key{keyboard.KeyF10}},
|
||||
{key: tbx.KeyF11, want: []keyboard.Key{keyboard.KeyF11}},
|
||||
{key: tbx.KeyF12, want: []keyboard.Key{keyboard.KeyF12}},
|
||||
{key: tbx.KeyInsert, want: []keyboard.Key{keyboard.KeyInsert}},
|
||||
{key: tbx.KeyDelete, want: []keyboard.Key{keyboard.KeyDelete}},
|
||||
{key: tbx.KeyHome, want: []keyboard.Key{keyboard.KeyHome}},
|
||||
{key: tbx.KeyEnd, want: []keyboard.Key{keyboard.KeyEnd}},
|
||||
{key: tbx.KeyPgup, want: []keyboard.Key{keyboard.KeyPgUp}},
|
||||
{key: tbx.KeyPgdn, want: []keyboard.Key{keyboard.KeyPgDn}},
|
||||
{key: tbx.KeyArrowUp, want: []keyboard.Key{keyboard.KeyArrowUp}},
|
||||
{key: tbx.KeyArrowDown, want: []keyboard.Key{keyboard.KeyArrowDown}},
|
||||
{key: tbx.KeyArrowLeft, want: []keyboard.Key{keyboard.KeyArrowLeft}},
|
||||
{key: tbx.KeyArrowRight, want: []keyboard.Key{keyboard.KeyArrowRight}},
|
||||
{key: tbx.KeyBackspace, want: []keyboard.Key{keyboard.KeyBackspace}},
|
||||
{key: tbx.KeyCtrlH, want: []keyboard.Key{keyboard.KeyBackspace}},
|
||||
{key: tbx.KeyTab, want: []keyboard.Key{keyboard.KeyTab}},
|
||||
{key: tbx.KeyCtrlI, want: []keyboard.Key{keyboard.KeyTab}},
|
||||
{key: tbx.KeyEnter, want: []keyboard.Key{keyboard.KeyEnter}},
|
||||
{key: tbx.KeyCtrlM, want: []keyboard.Key{keyboard.KeyEnter}},
|
||||
{key: tbx.KeyEsc, want: []keyboard.Key{keyboard.KeyEsc}},
|
||||
{key: tbx.KeyCtrlLsqBracket, want: []keyboard.Key{keyboard.KeyEsc}},
|
||||
{key: tbx.KeyCtrl3, want: []keyboard.Key{keyboard.KeyEsc}},
|
||||
{key: tbx.KeyCtrl2, want: []keyboard.Key{keyboard.KeyCtrl, '2'}},
|
||||
{key: tbx.KeyCtrlTilde, want: []keyboard.Key{keyboard.KeyCtrl, '2'}},
|
||||
{key: tbx.KeyCtrlSpace, want: []keyboard.Key{keyboard.KeyCtrl, '2'}},
|
||||
{key: tbx.KeyCtrl4, want: []keyboard.Key{keyboard.KeyCtrl, '4'}},
|
||||
{key: tbx.KeyCtrlBackslash, want: []keyboard.Key{keyboard.KeyCtrl, '4'}},
|
||||
{key: tbx.KeyCtrl5, want: []keyboard.Key{keyboard.KeyCtrl, '5'}},
|
||||
{key: tbx.KeyCtrlRsqBracket, want: []keyboard.Key{keyboard.KeyCtrl, '5'}},
|
||||
{key: tbx.KeyCtrl6, want: []keyboard.Key{keyboard.KeyCtrl, '6'}},
|
||||
{key: tbx.KeyCtrl7, want: []keyboard.Key{keyboard.KeyCtrl, '7'}},
|
||||
{key: tbx.KeyCtrlSlash, want: []keyboard.Key{keyboard.KeyCtrl, '7'}},
|
||||
{key: tbx.KeyCtrlUnderscore, want: []keyboard.Key{keyboard.KeyCtrl, '7'}},
|
||||
{key: tbx.KeyCtrl8, want: []keyboard.Key{keyboard.KeyCtrl, '8'}},
|
||||
{key: tbx.KeyCtrlA, want: []keyboard.Key{keyboard.KeyCtrl, 'a'}},
|
||||
{key: tbx.KeyCtrlB, want: []keyboard.Key{keyboard.KeyCtrl, 'b'}},
|
||||
{key: tbx.KeyCtrlC, want: []keyboard.Key{keyboard.KeyCtrl, 'c'}},
|
||||
{key: tbx.KeyCtrlD, want: []keyboard.Key{keyboard.KeyCtrl, 'd'}},
|
||||
{key: tbx.KeyCtrlE, want: []keyboard.Key{keyboard.KeyCtrl, 'e'}},
|
||||
{key: tbx.KeyCtrlF, want: []keyboard.Key{keyboard.KeyCtrl, 'f'}},
|
||||
{key: tbx.KeyCtrlG, want: []keyboard.Key{keyboard.KeyCtrl, 'g'}},
|
||||
{key: tbx.KeyCtrlJ, want: []keyboard.Key{keyboard.KeyCtrl, 'j'}},
|
||||
{key: tbx.KeyCtrlK, want: []keyboard.Key{keyboard.KeyCtrl, 'k'}},
|
||||
{key: tbx.KeyCtrlL, want: []keyboard.Key{keyboard.KeyCtrl, 'l'}},
|
||||
{key: tbx.KeyCtrlN, want: []keyboard.Key{keyboard.KeyCtrl, 'n'}},
|
||||
{key: tbx.KeyCtrlO, want: []keyboard.Key{keyboard.KeyCtrl, 'o'}},
|
||||
{key: tbx.KeyCtrlP, want: []keyboard.Key{keyboard.KeyCtrl, 'p'}},
|
||||
{key: tbx.KeyCtrlQ, want: []keyboard.Key{keyboard.KeyCtrl, 'q'}},
|
||||
{key: tbx.KeyCtrlR, want: []keyboard.Key{keyboard.KeyCtrl, 'r'}},
|
||||
{key: tbx.KeyCtrlS, want: []keyboard.Key{keyboard.KeyCtrl, 's'}},
|
||||
{key: tbx.KeyCtrlT, want: []keyboard.Key{keyboard.KeyCtrl, 't'}},
|
||||
{key: tbx.KeyCtrlU, want: []keyboard.Key{keyboard.KeyCtrl, 'u'}},
|
||||
{key: tbx.KeyCtrlV, want: []keyboard.Key{keyboard.KeyCtrl, 'v'}},
|
||||
{key: tbx.KeyCtrlW, want: []keyboard.Key{keyboard.KeyCtrl, 'w'}},
|
||||
{key: tbx.KeyCtrlX, want: []keyboard.Key{keyboard.KeyCtrl, 'x'}},
|
||||
{key: tbx.KeyCtrlY, want: []keyboard.Key{keyboard.KeyCtrl, 'y'}},
|
||||
{key: tbx.KeyCtrlZ, want: []keyboard.Key{keyboard.KeyCtrl, 'z'}},
|
||||
{ch: 'a', want: 'a'},
|
||||
{ch: 'A', want: 'A'},
|
||||
{ch: 'z', want: 'z'},
|
||||
{ch: 'Z', want: 'Z'},
|
||||
{ch: '0', want: '0'},
|
||||
{ch: '9', want: '9'},
|
||||
{ch: '!', want: '!'},
|
||||
{ch: ')', want: ')'},
|
||||
{key: tbx.KeySpace, want: keyboard.KeySpace},
|
||||
{key: tbx.KeyF1, want: keyboard.KeyF1},
|
||||
{key: tbx.KeyF2, want: keyboard.KeyF2},
|
||||
{key: tbx.KeyF3, want: keyboard.KeyF3},
|
||||
{key: tbx.KeyF4, want: keyboard.KeyF4},
|
||||
{key: tbx.KeyF5, want: keyboard.KeyF5},
|
||||
{key: tbx.KeyF6, want: keyboard.KeyF6},
|
||||
{key: tbx.KeyF7, want: keyboard.KeyF7},
|
||||
{key: tbx.KeyF8, want: keyboard.KeyF8},
|
||||
{key: tbx.KeyF9, want: keyboard.KeyF9},
|
||||
{key: tbx.KeyF10, want: keyboard.KeyF10},
|
||||
{key: tbx.KeyF11, want: keyboard.KeyF11},
|
||||
{key: tbx.KeyF12, want: keyboard.KeyF12},
|
||||
{key: tbx.KeyInsert, want: keyboard.KeyInsert},
|
||||
{key: tbx.KeyDelete, want: keyboard.KeyDelete},
|
||||
{key: tbx.KeyHome, want: keyboard.KeyHome},
|
||||
{key: tbx.KeyEnd, want: keyboard.KeyEnd},
|
||||
{key: tbx.KeyPgup, want: keyboard.KeyPgUp},
|
||||
{key: tbx.KeyPgdn, want: keyboard.KeyPgDn},
|
||||
{key: tbx.KeyArrowUp, want: keyboard.KeyArrowUp},
|
||||
{key: tbx.KeyArrowDown, want: keyboard.KeyArrowDown},
|
||||
{key: tbx.KeyArrowLeft, want: keyboard.KeyArrowLeft},
|
||||
{key: tbx.KeyArrowRight, want: keyboard.KeyArrowRight},
|
||||
{key: tbx.KeyCtrlTilde, want: keyboard.KeyCtrlTilde},
|
||||
{key: tbx.KeyCtrlTilde, want: keyboard.KeyCtrl2},
|
||||
{key: tbx.KeyCtrlTilde, want: keyboard.KeyCtrlSpace},
|
||||
{key: tbx.KeyCtrl2, want: keyboard.KeyCtrlTilde},
|
||||
{key: tbx.KeyCtrlSpace, want: keyboard.KeyCtrlTilde},
|
||||
{key: tbx.KeyCtrlA, want: keyboard.KeyCtrlA},
|
||||
{key: tbx.KeyCtrlB, want: keyboard.KeyCtrlB},
|
||||
{key: tbx.KeyCtrlC, want: keyboard.KeyCtrlC},
|
||||
{key: tbx.KeyCtrlD, want: keyboard.KeyCtrlD},
|
||||
{key: tbx.KeyCtrlE, want: keyboard.KeyCtrlE},
|
||||
{key: tbx.KeyCtrlF, want: keyboard.KeyCtrlF},
|
||||
{key: tbx.KeyCtrlG, want: keyboard.KeyCtrlG},
|
||||
{key: tbx.KeyBackspace, want: keyboard.KeyBackspace},
|
||||
{key: tbx.KeyBackspace, want: keyboard.KeyCtrlH},
|
||||
{key: tbx.KeyCtrlH, want: keyboard.KeyBackspace},
|
||||
{key: tbx.KeyTab, want: keyboard.KeyTab},
|
||||
{key: tbx.KeyTab, want: keyboard.KeyCtrlI},
|
||||
{key: tbx.KeyCtrlI, want: keyboard.KeyTab},
|
||||
{key: tbx.KeyCtrlJ, want: keyboard.KeyCtrlJ},
|
||||
{key: tbx.KeyCtrlK, want: keyboard.KeyCtrlK},
|
||||
{key: tbx.KeyCtrlL, want: keyboard.KeyCtrlL},
|
||||
{key: tbx.KeyEnter, want: keyboard.KeyEnter},
|
||||
{key: tbx.KeyEnter, want: keyboard.KeyCtrlM},
|
||||
{key: tbx.KeyCtrlM, want: keyboard.KeyEnter},
|
||||
{key: tbx.KeyCtrlN, want: keyboard.KeyCtrlN},
|
||||
{key: tbx.KeyCtrlO, want: keyboard.KeyCtrlO},
|
||||
{key: tbx.KeyCtrlP, want: keyboard.KeyCtrlP},
|
||||
{key: tbx.KeyCtrlQ, want: keyboard.KeyCtrlQ},
|
||||
{key: tbx.KeyCtrlR, want: keyboard.KeyCtrlR},
|
||||
{key: tbx.KeyCtrlS, want: keyboard.KeyCtrlS},
|
||||
{key: tbx.KeyCtrlT, want: keyboard.KeyCtrlT},
|
||||
{key: tbx.KeyCtrlU, want: keyboard.KeyCtrlU},
|
||||
{key: tbx.KeyCtrlV, want: keyboard.KeyCtrlV},
|
||||
{key: tbx.KeyCtrlW, want: keyboard.KeyCtrlW},
|
||||
{key: tbx.KeyCtrlX, want: keyboard.KeyCtrlX},
|
||||
{key: tbx.KeyCtrlY, want: keyboard.KeyCtrlY},
|
||||
{key: tbx.KeyCtrlZ, want: keyboard.KeyCtrlZ},
|
||||
{key: tbx.KeyEsc, want: keyboard.KeyEsc},
|
||||
{key: tbx.KeyEsc, want: keyboard.KeyCtrlLsqBracket},
|
||||
{key: tbx.KeyEsc, want: keyboard.KeyCtrl3},
|
||||
{key: tbx.KeyCtrlLsqBracket, want: keyboard.KeyEsc},
|
||||
{key: tbx.KeyCtrl3, want: keyboard.KeyEsc},
|
||||
{key: tbx.KeyCtrl4, want: keyboard.KeyCtrl4},
|
||||
{key: tbx.KeyCtrl4, want: keyboard.KeyCtrlBackslash},
|
||||
{key: tbx.KeyCtrlBackslash, want: keyboard.KeyCtrl4},
|
||||
{key: tbx.KeyCtrl5, want: keyboard.KeyCtrl5},
|
||||
{key: tbx.KeyCtrl5, want: keyboard.KeyCtrlRsqBracket},
|
||||
{key: tbx.KeyCtrlRsqBracket, want: keyboard.KeyCtrl5},
|
||||
{key: tbx.KeyCtrl6, want: keyboard.KeyCtrl6},
|
||||
{key: tbx.KeyCtrl7, want: keyboard.KeyCtrl7},
|
||||
{key: tbx.KeyCtrl7, want: keyboard.KeyCtrlSlash},
|
||||
{key: tbx.KeyCtrl7, want: keyboard.KeyCtrlUnderscore},
|
||||
{key: tbx.KeyCtrlSlash, want: keyboard.KeyCtrl7},
|
||||
{key: tbx.KeyCtrlUnderscore, want: keyboard.KeyCtrl7},
|
||||
{key: tbx.KeyBackspace2, want: keyboard.KeyBackspace2},
|
||||
{key: tbx.KeyBackspace2, want: keyboard.KeyCtrl8},
|
||||
{key: tbx.KeyCtrl8, want: keyboard.KeyBackspace2},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@ -284,34 +297,27 @@ func TestKeyboardKeys(t *testing.T) {
|
||||
})
|
||||
|
||||
gotCount := len(evs)
|
||||
var wantCount int
|
||||
if tc.wantErr {
|
||||
wantCount = 1
|
||||
} else {
|
||||
wantCount = len(tc.want)
|
||||
}
|
||||
|
||||
wantCount := 1
|
||||
if gotCount != wantCount {
|
||||
t.Fatalf("toTermdashEvents => got %d events, want %d, events were:\n%v", gotCount, wantCount, pretty.Sprint(evs))
|
||||
}
|
||||
ev := evs[0]
|
||||
|
||||
for i, ev := range evs {
|
||||
if err, ok := ev.(*terminalapi.Error); ok != tc.wantErr {
|
||||
t.Fatalf("toTermdashEvents => unexpected error:%v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
if _, ok := ev.(*terminalapi.Error); ok {
|
||||
return
|
||||
if err, ok := ev.(*terminalapi.Error); ok != tc.wantErr {
|
||||
t.Fatalf("toTermdashEvents => unexpected error:%v, wantErr: %v", err, tc.wantErr)
|
||||
}
|
||||
if _, ok := ev.(*terminalapi.Error); ok {
|
||||
return
|
||||
}
|
||||
|
||||
switch e := ev.(type) {
|
||||
case *terminalapi.Keyboard:
|
||||
if got, want := e.Key, tc.want; got != want {
|
||||
t.Errorf("toTermdashEvents => got key %v, want %v", got, want)
|
||||
}
|
||||
|
||||
switch e := ev.(type) {
|
||||
case *terminalapi.Keyboard:
|
||||
if got, want := e.Key, tc.want[i]; got != want {
|
||||
t.Errorf("toTermdashEvents => got key[%d] %v, want %v", got, i, want)
|
||||
}
|
||||
|
||||
default:
|
||||
t.Fatalf("toTermdashEvents => unexpected event type %T", e)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("toTermdashEvents => unexpected event type %T", e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
417
widgets/textinput/editor.go
Normal file
417
widgets/textinput/editor.go
Normal file
@ -0,0 +1,417 @@
|
||||
// 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 textinput
|
||||
|
||||
// editor.go contains data types that edit the content of the text input field.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mum4k/termdash/internal/numbers"
|
||||
"github.com/mum4k/termdash/internal/runewidth"
|
||||
)
|
||||
|
||||
// fieldData are the data currently present inside the text input field.
|
||||
type fieldData []rune
|
||||
|
||||
// String implements fmt.Stringer.
|
||||
func (fd fieldData) String() string {
|
||||
var b strings.Builder
|
||||
for _, r := range fd {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return fmt.Sprintf("%q", b.String())
|
||||
}
|
||||
|
||||
// insertAt inserts rune at the specified index.
|
||||
func (fd *fieldData) insertAt(idx int, r rune) {
|
||||
*fd = append(
|
||||
(*fd)[:idx],
|
||||
append(fieldData{r}, (*fd)[idx:]...)...,
|
||||
)
|
||||
}
|
||||
|
||||
// deleteAt deletes rune at the specified index.
|
||||
func (fd *fieldData) deleteAt(idx int) {
|
||||
*fd = append((*fd)[:idx], (*fd)[idx+1:]...)
|
||||
}
|
||||
|
||||
// cellsBefore given an endIdx calculates startIdx that results in range that
|
||||
// will take at most the provided number of cells to print on the screen.
|
||||
func (fd *fieldData) cellsBefore(cells, endIdx int) int {
|
||||
if endIdx == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
usedCells := 0
|
||||
for i := endIdx; i > 0; i-- {
|
||||
prev := (*fd)[i-1]
|
||||
width := runewidth.RuneWidth(prev)
|
||||
|
||||
if usedCells+width > cells {
|
||||
return i
|
||||
}
|
||||
usedCells += width
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// cellsAfter given a startIdx calculates endIdx that results in range that
|
||||
// will take at most the provided number of cells to print on the screen.
|
||||
func (fd *fieldData) cellsAfter(cells, startIdx int) int {
|
||||
if startIdx >= len(*fd) || cells == 0 {
|
||||
return startIdx
|
||||
}
|
||||
|
||||
first := (*fd)[startIdx]
|
||||
usedCells := runewidth.RuneWidth(first)
|
||||
for i := startIdx + 1; i < len(*fd); i++ {
|
||||
r := (*fd)[i]
|
||||
width := runewidth.RuneWidth(r)
|
||||
if usedCells+width > cells {
|
||||
return i
|
||||
}
|
||||
usedCells += width
|
||||
}
|
||||
return len(*fd)
|
||||
}
|
||||
|
||||
// minForArrows is the smallest number of cells in the window where we can
|
||||
// indicate hidden text with left and right arrow.
|
||||
const minForArrows = 3
|
||||
|
||||
// curMinIdx returns the lowest acceptable index for cursor position that is
|
||||
// still within the visible range.
|
||||
func curMinIdx(start, cells int) int {
|
||||
if start == 0 || cells < minForArrows {
|
||||
// The very first rune is visible, so the cursor can go all the way to
|
||||
// the start.
|
||||
return start
|
||||
}
|
||||
|
||||
// When the first rune isn't visible, the cursor cannot go on the first
|
||||
// cell in the visible range since it contains the left arrow.
|
||||
return start + 1
|
||||
}
|
||||
|
||||
// curMaxIdx returns the highest acceptable index for cursor position that is
|
||||
// still within the visible range given the number of runes in data.
|
||||
func curMaxIdx(start, end, cells, runeCount int) int {
|
||||
if end == runeCount+1 || cells < minForArrows {
|
||||
// The last rune is visible, so the cursor can go all the way to the
|
||||
// end.
|
||||
return end - 1
|
||||
}
|
||||
|
||||
// When the last rune isn't visible, the cursor cannot go on the last cell
|
||||
// in the window that is reserved for appending text, since it contains the
|
||||
// right arrow.
|
||||
return end - 2
|
||||
}
|
||||
|
||||
// shiftLeft shifts the visible range left so that it again contains the
|
||||
// cursor.
|
||||
// The visible range includes all fieldData indexes
|
||||
// in range start <= idx < end.
|
||||
func (fd *fieldData) shiftLeft(start, cells, curDataPos int) (int, int) {
|
||||
var startIdx int
|
||||
switch {
|
||||
case curDataPos == 0 || cells < minForArrows:
|
||||
startIdx = curDataPos
|
||||
|
||||
default:
|
||||
startIdx = curDataPos - 1
|
||||
}
|
||||
forRunes := cells - 1
|
||||
endIdx := fd.cellsAfter(forRunes, startIdx)
|
||||
endIdx++ // Space for the cursor.
|
||||
|
||||
return startIdx, endIdx
|
||||
}
|
||||
|
||||
// shiftRight shifts the visible range right so that it again contains the
|
||||
// cursor.
|
||||
// The visible range includes all fieldData indexes
|
||||
// in range start <= idx < end.
|
||||
func (fd *fieldData) shiftRight(start, cells, curDataPos int) (int, int) {
|
||||
var endIdx int
|
||||
switch dataLen := len(*fd); {
|
||||
case curDataPos == dataLen:
|
||||
// Cursor is in the empty space after the data.
|
||||
// Print all runes until the end of data.
|
||||
endIdx = dataLen
|
||||
|
||||
default:
|
||||
// Cursor is within the data, print all runes including the one the
|
||||
// cursor is on.
|
||||
endIdx = curDataPos + 1
|
||||
}
|
||||
|
||||
forRunes := cells - 1
|
||||
startIdx := fd.cellsBefore(forRunes, endIdx)
|
||||
|
||||
// Invariant, if counting form the back ends in the middle of a full-width
|
||||
// rune, cellsAfter doesn't include the full-width rune. This means that we
|
||||
// might have recovered space for one half-with rune at the end if there is
|
||||
// one.
|
||||
endIdx = fd.cellsAfter(forRunes, startIdx)
|
||||
endIdx++ // Space for the cursor.
|
||||
|
||||
return startIdx, endIdx
|
||||
}
|
||||
|
||||
// lastVisible given an end index of visible range asserts whether the last
|
||||
// rune in the data is visible.
|
||||
// The visible range includes all fieldData indexes
|
||||
// in range start <= idx < end.
|
||||
func (fd *fieldData) lastVisible(end int) bool {
|
||||
return end-1 >= len(*fd)
|
||||
}
|
||||
|
||||
// runesIn returns all the runes in the visible range.
|
||||
// The visible range includes all fieldData indexes
|
||||
// in range start <= idx < end.
|
||||
func (fd *fieldData) runesIn(start, end int) []rune {
|
||||
var runes []rune
|
||||
for i, r := range (*fd)[start:] {
|
||||
if i+start > end-2 { // One last space is for the cursor after the text.
|
||||
break
|
||||
}
|
||||
runes = append(runes, r)
|
||||
}
|
||||
return runes
|
||||
}
|
||||
|
||||
// fitRunes starting from the firstRune index returns runes that take at most
|
||||
// the specified number of cells. The last cell is reserved for a cursor
|
||||
// position used for appending new runes.
|
||||
// This might return smaller number of runes than the size of the range,
|
||||
// depending on the width of the individual runes.
|
||||
// Returns the text and the start and end positions within the data.
|
||||
func (fd *fieldData) fitRunes(firstRune, curPos, cells int) (string, int, int) {
|
||||
forRunes := cells - 1 // One cell reserved for the cursor when appending.
|
||||
|
||||
// Determine how many runes fit from the start.
|
||||
start := firstRune
|
||||
end := fd.cellsAfter(forRunes, start)
|
||||
end++
|
||||
|
||||
if start > 0 && fd.lastVisible(end) {
|
||||
// Start is in the middle, end is visible.
|
||||
// Fit runes from the end.
|
||||
end = len(*fd)
|
||||
start = fd.cellsBefore(forRunes, end)
|
||||
end++ // Space for the cursor within the visible range.
|
||||
}
|
||||
|
||||
// The fitting of runes might have resulted in a visible range that no
|
||||
// longer contains the cursor (it became shorter) or the cursor was outside
|
||||
// to begin with (due to cursorLeft() or cursorRight() calls).
|
||||
// Shift the range so the cursor is again inside.
|
||||
if curPos < curMinIdx(start, cells) {
|
||||
start, end = fd.shiftLeft(start, cells, curPos)
|
||||
} else if curPos > curMaxIdx(start, end, cells, len(*fd)) {
|
||||
start, end = fd.shiftRight(start, cells, curPos)
|
||||
}
|
||||
|
||||
runes := fd.runesIn(start, end)
|
||||
useArrows := cells >= minForArrows
|
||||
var b strings.Builder
|
||||
for i, r := range runes {
|
||||
switch {
|
||||
case useArrows && i == 0 && start > 0:
|
||||
// Indicate that start is hidden by replacing the first visible
|
||||
// rune with an arrow.
|
||||
b.WriteRune('⇦')
|
||||
if rw := runewidth.RuneWidth(r); rw == 2 {
|
||||
// If the replaced rune was a full-width rune, place two arrows
|
||||
// to keep the same space allocation as pre-calculated.
|
||||
b.WriteRune('⇦')
|
||||
}
|
||||
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
if useArrows && !fd.lastVisible(end) {
|
||||
// Indicate that end is hidden by placing an arrow at the end.
|
||||
// THis has no impact on space allocation, since the last cell is
|
||||
// always reserved for the cursor or the arrow.
|
||||
b.WriteRune('⇨')
|
||||
}
|
||||
return b.String(), start, end
|
||||
}
|
||||
|
||||
// fieldEditor maintains the cursor position and allows editing of the data in
|
||||
// the text input field.
|
||||
// This object isn't thread-safe.
|
||||
type fieldEditor struct {
|
||||
// data are the data currently present in the text input field.
|
||||
data fieldData
|
||||
|
||||
// curDataPos is the current position of the cursor within the data.
|
||||
// The cursor is allowed to go one cell beyond the data so appending is
|
||||
// possible.
|
||||
curDataPos int
|
||||
|
||||
// firstRune is the index of the first displayed rune in the text input
|
||||
// field.
|
||||
firstRune int
|
||||
|
||||
// width is the width of the text input field last time viewFor was called.
|
||||
width int
|
||||
}
|
||||
|
||||
// newFieldEditor returns a new fieldEditor instance.
|
||||
func newFieldEditor() *fieldEditor {
|
||||
return &fieldEditor{}
|
||||
}
|
||||
|
||||
// minFieldWidth is the minimum supported width of the text input field.
|
||||
const minFieldWidth = 4
|
||||
|
||||
// curCell returns the index of the cell the cursor is in within the text input field.
|
||||
func (fe *fieldEditor) curCell(width int) int {
|
||||
if width == 0 {
|
||||
return 0
|
||||
}
|
||||
// The index of rune within the visible range the cursor is at.
|
||||
runeNum := fe.curDataPos - fe.firstRune
|
||||
|
||||
cellNum := 0
|
||||
rn := 0
|
||||
for i, r := range fe.data {
|
||||
if i < fe.firstRune {
|
||||
continue
|
||||
}
|
||||
if rn >= runeNum {
|
||||
break
|
||||
}
|
||||
rn++
|
||||
cellNum += runewidth.RuneWidth(r)
|
||||
}
|
||||
return cellNum
|
||||
}
|
||||
|
||||
// viewFor returns the currently visible data inside a text field with the
|
||||
// specified width and the cursor position within the field.
|
||||
func (fe *fieldEditor) viewFor(width int) (string, int, error) {
|
||||
if min := minFieldWidth; width < min { // One for left arrow, two for one full-width rune and one for the cursor.
|
||||
return "", -1, fmt.Errorf("width %d is too small, the minimum is %d", width, min)
|
||||
}
|
||||
runes, start, _ := fe.data.fitRunes(fe.firstRune, fe.curDataPos, width)
|
||||
fe.firstRune = start
|
||||
fe.width = width
|
||||
return runes, fe.curCell(width), nil
|
||||
}
|
||||
|
||||
// content returns the string content in the field editor.
|
||||
func (fe *fieldEditor) content() string {
|
||||
return string(fe.data)
|
||||
}
|
||||
|
||||
// reset resets the content back to zero.
|
||||
func (fe *fieldEditor) reset() {
|
||||
*fe = *newFieldEditor()
|
||||
}
|
||||
|
||||
// insert inserts the rune at the current position of the cursor.
|
||||
func (fe *fieldEditor) insert(r rune) {
|
||||
rw := runewidth.RuneWidth(r)
|
||||
if rw == 0 {
|
||||
// Don't insert invisible runes.
|
||||
return
|
||||
}
|
||||
fe.data.insertAt(fe.curDataPos, r)
|
||||
fe.curDataPos++
|
||||
}
|
||||
|
||||
// delete deletes the rune at the current position of the cursor.
|
||||
func (fe *fieldEditor) delete() {
|
||||
if fe.curDataPos >= len(fe.data) {
|
||||
// Cursor not on a rune, nothing to do.
|
||||
return
|
||||
}
|
||||
fe.data.deleteAt(fe.curDataPos)
|
||||
}
|
||||
|
||||
// deleteBefore deletes the rune that is immediately to the left of the cursor.
|
||||
func (fe *fieldEditor) deleteBefore() {
|
||||
if fe.curDataPos == 0 {
|
||||
// Cursor at the beginning, nothing to do.
|
||||
return
|
||||
}
|
||||
fe.cursorLeft()
|
||||
fe.delete()
|
||||
}
|
||||
|
||||
// cursorRight moves the cursor one position to the right.
|
||||
func (fe *fieldEditor) cursorRight() {
|
||||
fe.curDataPos, _ = numbers.MinMaxInts([]int{fe.curDataPos + 1, len(fe.data)})
|
||||
}
|
||||
|
||||
// cursorLeft moves the cursor one position to the left.
|
||||
func (fe *fieldEditor) cursorLeft() {
|
||||
_, fe.curDataPos = numbers.MinMaxInts([]int{fe.curDataPos - 1, 0})
|
||||
}
|
||||
|
||||
// cursorStart moves the cursor to the beginning of the data.
|
||||
func (fe *fieldEditor) cursorStart() {
|
||||
fe.curDataPos = 0
|
||||
}
|
||||
|
||||
// cursorEnd moves the cursor to the end of the data.
|
||||
func (fe *fieldEditor) cursorEnd() {
|
||||
fe.curDataPos = len(fe.data)
|
||||
}
|
||||
|
||||
// cursorRelCell sets the cursor onto the cell index within the visible
|
||||
// area.
|
||||
// If the index falls before the window, the cursor is moved onto the first
|
||||
// visible position.
|
||||
// If the pos falls after the end of data, the cursor is moved onto the last
|
||||
// visible position.
|
||||
func (fe *fieldEditor) cursorRelCell(cellIdx int) {
|
||||
runes, start, end := fe.data.fitRunes(fe.firstRune, fe.curDataPos, fe.width)
|
||||
minDataIdx := curMinIdx(start, fe.width)
|
||||
maxDataIdx := curMaxIdx(start, end, fe.width, len(fe.data))
|
||||
|
||||
// Index of the rune we should move the cursor to relative to the visible
|
||||
// range.
|
||||
var relRuneIdx int
|
||||
var cell int
|
||||
for _, r := range runes {
|
||||
cell += runewidth.RuneWidth(r)
|
||||
if cell > cellIdx {
|
||||
break
|
||||
}
|
||||
relRuneIdx++
|
||||
}
|
||||
|
||||
// Absolute index of the rune we should move the cursor to.
|
||||
dataIdx := fe.firstRune + relRuneIdx
|
||||
switch {
|
||||
case dataIdx < minDataIdx:
|
||||
fe.curDataPos = minDataIdx
|
||||
|
||||
case dataIdx > maxDataIdx:
|
||||
fe.curDataPos = maxDataIdx
|
||||
|
||||
default:
|
||||
fe.curDataPos = dataIdx
|
||||
}
|
||||
}
|
1813
widgets/textinput/editor_test.go
Normal file
1813
widgets/textinput/editor_test.go
Normal file
File diff suppressed because it is too large
Load Diff
265
widgets/textinput/options.go
Normal file
265
widgets/textinput/options.go
Normal file
@ -0,0 +1,265 @@
|
||||
// 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 textinput
|
||||
|
||||
// options.go contains configurable options for TextInput.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/runewidth"
|
||||
"github.com/mum4k/termdash/internal/wrap"
|
||||
"github.com/mum4k/termdash/linestyle"
|
||||
)
|
||||
|
||||
// Option is used to provide options.
|
||||
type Option interface {
|
||||
// set sets the provided option.
|
||||
set(*options)
|
||||
}
|
||||
|
||||
// option implements Option.
|
||||
type option func(*options)
|
||||
|
||||
// set implements Option.set.
|
||||
func (o option) set(opts *options) {
|
||||
o(opts)
|
||||
}
|
||||
|
||||
// options holds the provided options.
|
||||
type options struct {
|
||||
fillColor cell.Color
|
||||
textColor cell.Color
|
||||
placeHolderColor cell.Color
|
||||
highlightedColor cell.Color
|
||||
cursorColor cell.Color
|
||||
border linestyle.LineStyle
|
||||
borderColor cell.Color
|
||||
|
||||
widthPerc *int
|
||||
maxWidthCells *int
|
||||
label string
|
||||
labelCellOpts []cell.Option
|
||||
labelAlign align.Horizontal
|
||||
|
||||
placeHolder string
|
||||
hideTextWith rune
|
||||
|
||||
filter FilterFn
|
||||
onSubmit SubmitFn
|
||||
clearOnSubmit bool
|
||||
}
|
||||
|
||||
// validate validates the provided options.
|
||||
func (o *options) validate() error {
|
||||
if min, max, perc := 0, 100, o.widthPerc; perc != nil && (*perc <= min || *perc > max) {
|
||||
return fmt.Errorf("invalid WidthPerc(%d), must be value in range %d < value <= %d", *perc, min, max)
|
||||
}
|
||||
if min, cells := 4, o.maxWidthCells; cells != nil && *cells < min {
|
||||
return fmt.Errorf("invalid MaxWidthCells(%d), must be value in range %d <= value", *cells, min)
|
||||
}
|
||||
if r := o.hideTextWith; r != 0 {
|
||||
if err := wrap.ValidText(string(r)); err != nil {
|
||||
return fmt.Errorf("invalid HideTextWidth rune %c(%d): %v", r, r, err)
|
||||
}
|
||||
if got, want := runewidth.RuneWidth(r), 1; got != want {
|
||||
return fmt.Errorf("invalid HideTextWidth rune %c(%d), has rune width of %d cells, only runes with width of %d are accepted", r, r, got, want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newOptions returns options with the default values set.
|
||||
func newOptions() *options {
|
||||
return &options{
|
||||
fillColor: cell.ColorNumber(DefaultFillColorNumber),
|
||||
placeHolderColor: cell.ColorNumber(DefaultPlaceHolderColorNumber),
|
||||
highlightedColor: cell.ColorNumber(DefaultHighlightedColorNumber),
|
||||
cursorColor: cell.ColorNumber(DefaultCursorColorNumber),
|
||||
labelAlign: DefaultLabelAlign,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultFillColorNumber is the default color number for the FillColor option.
|
||||
const DefaultFillColorNumber = 33
|
||||
|
||||
// FillColor sets the fill color for the text input field.
|
||||
// Defaults to DefaultFillColorNumber.
|
||||
func FillColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.fillColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// TextColor sets the color of the text in the input field.
|
||||
// Defaults to the default terminal color.
|
||||
func TextColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.textColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultHighlightedColorNumber is the default color number for the
|
||||
// HighlightedColor option.
|
||||
const DefaultHighlightedColorNumber = 0
|
||||
|
||||
// HighlightedColor sets the color of the text rune directly under the cursor.
|
||||
// Defaults to the default terminal color.
|
||||
func HighlightedColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.highlightedColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultCursorColorNumber is the default color number for the CursorColor
|
||||
// option.
|
||||
const DefaultCursorColorNumber = 250
|
||||
|
||||
// CursorColor sets the color of the cursor.
|
||||
// Defaults to DefaultCursorColorNumber.
|
||||
func CursorColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.cursorColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// Border adds a border around the text input field.
|
||||
func Border(ls linestyle.LineStyle) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.border = ls
|
||||
})
|
||||
}
|
||||
|
||||
// BorderColor sets the color of the border.
|
||||
// Defaults to the default terminal color.
|
||||
func BorderColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.borderColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// WidthPerc sets the width for the text input field as a percentage of the
|
||||
// container width. Must be a value in the range 0 < perc <= 100.
|
||||
// Defaults to the width adjusted automatically base on the label length.
|
||||
func WidthPerc(perc int) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.widthPerc = &perc
|
||||
})
|
||||
}
|
||||
|
||||
// MaxWidthCells sets the maximum width of the text input field as an absolute value
|
||||
// in cells. Must be a value in the range 4 <= cells.
|
||||
// This doesn't limit the text that the user can input, if the text overflows
|
||||
// the width of the input field, it scrolls to the left.
|
||||
// Defaults to using all available width in the container.
|
||||
func MaxWidthCells(cells int) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.maxWidthCells = &cells
|
||||
})
|
||||
}
|
||||
|
||||
// Label adds a text label to the left of the input field.
|
||||
func Label(label string, cOpts ...cell.Option) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.label = label
|
||||
opts.labelCellOpts = cOpts
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultLabelAlign is the default value for the LabelAlign option.
|
||||
const DefaultLabelAlign = align.HorizontalLeft
|
||||
|
||||
// LabelAlign sets the alignment of the label within its area.
|
||||
// The label is placed to the left of the input field. The width of this area
|
||||
// can be specified using the LabelWidthPerc option.
|
||||
// Defaults to DefaultLabelAlign.
|
||||
func LabelAlign(la align.Horizontal) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.labelAlign = la
|
||||
})
|
||||
}
|
||||
|
||||
// PlaceHolder sets text to be displayed in the input field when it is empty.
|
||||
// This text disappears when the text input field becomes focused.
|
||||
func PlaceHolder(text string) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.placeHolder = text
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultPlaceHolderColorNumber is the default color number for the
|
||||
// PlaceHolderColor option.
|
||||
const DefaultPlaceHolderColorNumber = 194
|
||||
|
||||
// PlaceHolderColor sets the color of the placeholder text.
|
||||
// Defaults to DefaultPlaceHolderColorNumber.
|
||||
func PlaceHolderColor(c cell.Color) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.placeHolderColor = c
|
||||
})
|
||||
}
|
||||
|
||||
// HideTextWith sets the rune that should be displayed instead of displaying
|
||||
// the text. Useful for fields that accept sensitive information like
|
||||
// passwords.
|
||||
// The rune must be a printable rune with cell width of one.
|
||||
func HideTextWith(r rune) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.hideTextWith = r
|
||||
})
|
||||
}
|
||||
|
||||
// FilterFn if provided can be used to filter runes that are allowed in the
|
||||
// text input field. Any rune for which this function returns false will be
|
||||
// rejected.
|
||||
type FilterFn func(rune) bool
|
||||
|
||||
// Filter sets a function that will be used to filter characters the user can
|
||||
// input.
|
||||
func Filter(fn FilterFn) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.filter = fn
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitFn if provided is called when the user submits the content of the text
|
||||
// input field, the argument text contains all the text in the field.
|
||||
// Submitting the input field clears its content.
|
||||
//
|
||||
// The callback function must be thread-safe as the keyboard event that
|
||||
// triggers the submission comes from a separate goroutine.
|
||||
type SubmitFn func(text string) error
|
||||
|
||||
// OnSubmit sets a function that will be called with the text typed by the user
|
||||
// when they submit the content by pressing the Enter key.
|
||||
// The SubmitFn must not attempt to read from or modify the TextInput instance
|
||||
// in any way as while the SubmitFn is executing, the TextInput is mutex
|
||||
// locked. If the intention is to clear the content on submission, use the
|
||||
// ClearOnSubmit() option.
|
||||
func OnSubmit(fn SubmitFn) Option {
|
||||
return option(func(opts *options) {
|
||||
opts.onSubmit = fn
|
||||
})
|
||||
}
|
||||
|
||||
// ClearOnSubmit sets the text input to be cleared when a submit of the content
|
||||
// is triggered by the user pressing the Enter key.
|
||||
func ClearOnSubmit() Option {
|
||||
return option(func(opts *options) {
|
||||
opts.clearOnSubmit = true
|
||||
})
|
||||
}
|
377
widgets/textinput/textinput.go
Normal file
377
widgets/textinput/textinput.go
Normal file
@ -0,0 +1,377 @@
|
||||
// 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 textinput implements a widget that accepts text input.
|
||||
package textinput
|
||||
|
||||
import (
|
||||
"image"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/internal/alignfor"
|
||||
"github.com/mum4k/termdash/internal/area"
|
||||
"github.com/mum4k/termdash/internal/canvas"
|
||||
"github.com/mum4k/termdash/internal/draw"
|
||||
"github.com/mum4k/termdash/internal/runewidth"
|
||||
"github.com/mum4k/termdash/internal/wrap"
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/linestyle"
|
||||
"github.com/mum4k/termdash/mouse"
|
||||
"github.com/mum4k/termdash/terminal/terminalapi"
|
||||
"github.com/mum4k/termdash/widgetapi"
|
||||
)
|
||||
|
||||
// TextInput accepts text input from the user.
|
||||
//
|
||||
// Displays an input field and an optional text label. The input field allows
|
||||
// the user to edit and submit text.
|
||||
//
|
||||
// The text can be submitted by pressing enter or read at any time by calling
|
||||
// Read. The text input field can be navigated using arrows, the Home and End
|
||||
// button and using mouse.
|
||||
//
|
||||
// Implements widgetapi.Widget. This object is thread-safe.
|
||||
type TextInput struct {
|
||||
// mu protects the widget.
|
||||
mu sync.Mutex
|
||||
|
||||
// editor tracks the edits and the state of the text input field.
|
||||
editor *fieldEditor
|
||||
|
||||
// forField is the area that was occupied by the text input field last
|
||||
// time Draw() was called.
|
||||
forField image.Rectangle
|
||||
|
||||
// opts are the provided options.
|
||||
opts *options
|
||||
}
|
||||
|
||||
// New returns a new TextInput.
|
||||
func New(opts ...Option) (*TextInput, error) {
|
||||
opt := newOptions()
|
||||
for _, o := range opts {
|
||||
o.set(opt)
|
||||
}
|
||||
if err := opt.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TextInput{
|
||||
editor: newFieldEditor(),
|
||||
opts: opt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Vars to be replaced from tests.
|
||||
var (
|
||||
// textFieldRune is the rune used in cells reserved for the text input
|
||||
// field if no text is present.
|
||||
// Changed from tests to provide readable test failures.
|
||||
textFieldRune rune
|
||||
|
||||
// cursorRune is rune that represents the cursor position.
|
||||
cursorRune rune
|
||||
)
|
||||
|
||||
// Read reads the content of the text input field.
|
||||
func (ti *TextInput) Read() string {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
|
||||
return ti.editor.content()
|
||||
}
|
||||
|
||||
// ReadAndClear reads the content of the text input field and clears it.
|
||||
func (ti *TextInput) ReadAndClear() string {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
|
||||
c := ti.editor.content()
|
||||
ti.editor.reset()
|
||||
return c
|
||||
}
|
||||
|
||||
// drawLabel draws the text label in the area.
|
||||
func (ti *TextInput) drawLabel(cvs *canvas.Canvas, labelAr image.Rectangle) error {
|
||||
start, err := alignfor.Text(labelAr, ti.opts.label, ti.opts.labelAlign, align.VerticalMiddle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := draw.Text(
|
||||
cvs, ti.opts.label, start,
|
||||
draw.TextOverrunMode(draw.OverrunModeThreeDot),
|
||||
draw.TextMaxX(labelAr.Max.X),
|
||||
draw.TextCellOpts(ti.opts.labelCellOpts...),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawField draws the text input field.
|
||||
func (ti *TextInput) drawField(cvs *canvas.Canvas, text string) error {
|
||||
if err := cvs.SetAreaCells(ti.forField, textFieldRune, cell.BgColor(ti.opts.fillColor)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ti.opts.hideTextWith != 0 {
|
||||
text = hideText(text, ti.opts.hideTextWith)
|
||||
}
|
||||
|
||||
if err := draw.Text(
|
||||
cvs, text, ti.forField.Min,
|
||||
draw.TextMaxX(ti.forField.Max.X),
|
||||
draw.TextCellOpts(cell.FgColor(ti.opts.textColor)),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drawCursor draws the cursor within the text input field.
|
||||
func (ti *TextInput) drawCursor(cvs *canvas.Canvas, curPos int) error {
|
||||
p := image.Point{
|
||||
curPos + ti.forField.Min.X,
|
||||
ti.forField.Min.Y,
|
||||
}
|
||||
if err := cvs.SetCellOpts(
|
||||
p,
|
||||
cell.FgColor(ti.opts.highlightedColor),
|
||||
cell.BgColor(ti.opts.cursorColor),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if cursorRune != 0 {
|
||||
if _, err := cvs.SetCell(p, cursorRune); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Draw draws the TextInput widget onto the canvas.
|
||||
// Implements widgetapi.Widget.Draw.
|
||||
func (ti *TextInput) Draw(cvs *canvas.Canvas, meta *widgetapi.Meta) error {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
|
||||
labelAr, textAr, err := split(cvs.Area(), ti.opts.label, ti.opts.widthPerc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ti.opts.border != linestyle.None {
|
||||
ti.forField = area.ExcludeBorder(textAr)
|
||||
} else {
|
||||
ti.forField = textAr
|
||||
}
|
||||
|
||||
if ti.forField.Dx() < minFieldWidth || ti.forField.Dy() < minFieldHeight {
|
||||
return draw.ResizeNeeded(cvs)
|
||||
}
|
||||
|
||||
if !labelAr.Eq(image.ZR) {
|
||||
if err := ti.drawLabel(cvs, labelAr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ti.opts.border != linestyle.None {
|
||||
if err := draw.Border(cvs, textAr, draw.BorderCellOpts(cell.FgColor(ti.opts.borderColor))); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
text, curPos, err := ti.editor.viewFor(ti.forField.Dx())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ti.drawField(cvs, text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if meta.Focused {
|
||||
if err := ti.drawCursor(cvs, curPos); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if ti.opts.placeHolder != "" && text == "" {
|
||||
if err := draw.Text(
|
||||
cvs, ti.opts.placeHolder, ti.forField.Min,
|
||||
draw.TextMaxX(ti.forField.Max.X),
|
||||
draw.TextCellOpts(cell.FgColor(ti.opts.placeHolderColor)),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keyboard processes keyboard events.
|
||||
// Implements widgetapi.Widget.Keyboard.
|
||||
func (ti *TextInput) Keyboard(k *terminalapi.Keyboard) error {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
|
||||
switch k.Key {
|
||||
case keyboard.KeyBackspace, keyboard.KeyBackspace2:
|
||||
ti.editor.deleteBefore()
|
||||
|
||||
case keyboard.KeyDelete:
|
||||
ti.editor.delete()
|
||||
|
||||
case keyboard.KeyArrowLeft:
|
||||
ti.editor.cursorLeft()
|
||||
|
||||
case keyboard.KeyArrowRight:
|
||||
ti.editor.cursorRight()
|
||||
|
||||
case keyboard.KeyHome, keyboard.KeyCtrlA:
|
||||
ti.editor.cursorStart()
|
||||
|
||||
case keyboard.KeyEnd, keyboard.KeyCtrlE:
|
||||
ti.editor.cursorEnd()
|
||||
|
||||
case keyboard.KeyEnter:
|
||||
text := ti.editor.content()
|
||||
if ti.opts.clearOnSubmit {
|
||||
ti.editor.reset()
|
||||
}
|
||||
if ti.opts.onSubmit != nil {
|
||||
return ti.opts.onSubmit(text)
|
||||
}
|
||||
|
||||
default:
|
||||
if err := wrap.ValidText(string(k.Key)); err != nil {
|
||||
// Ignore unsupported runes.
|
||||
return nil
|
||||
}
|
||||
if ti.opts.filter != nil && !ti.opts.filter(rune(k.Key)) {
|
||||
// Ignore filtered runes.
|
||||
return nil
|
||||
}
|
||||
ti.editor.insert(rune(k.Key))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mouse processes mouse events.
|
||||
// Implements widgetapi.Widget.Mouse.
|
||||
func (ti *TextInput) Mouse(m *terminalapi.Mouse) error {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
|
||||
if m.Button != mouse.ButtonLeft || !m.Position.In(ti.forField) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cellIdx := m.Position.X - ti.forField.Min.X
|
||||
ti.editor.cursorRelCell(cellIdx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// minFieldHeight is the minimum height in cells needed for the text input field.
|
||||
const minFieldHeight = 1
|
||||
|
||||
// Options implements widgetapi.Widget.Options.
|
||||
func (ti *TextInput) Options() widgetapi.Options {
|
||||
ti.mu.Lock()
|
||||
defer ti.mu.Unlock()
|
||||
|
||||
needWidth := minFieldWidth
|
||||
if lw := runewidth.StringWidth(ti.opts.label); lw > 0 {
|
||||
needWidth += lw
|
||||
}
|
||||
|
||||
needHeight := minFieldHeight
|
||||
if ti.opts.border != linestyle.None {
|
||||
needWidth += 2
|
||||
needHeight += 2
|
||||
}
|
||||
|
||||
maxWidth := 0
|
||||
if ti.opts.maxWidthCells != nil {
|
||||
additional := *ti.opts.maxWidthCells - minFieldWidth
|
||||
maxWidth = needWidth + additional
|
||||
}
|
||||
|
||||
return widgetapi.Options{
|
||||
MinimumSize: image.Point{
|
||||
needWidth,
|
||||
needHeight,
|
||||
},
|
||||
MaximumSize: image.Point{
|
||||
maxWidth,
|
||||
needHeight,
|
||||
},
|
||||
WantKeyboard: widgetapi.KeyScopeFocused,
|
||||
WantMouse: widgetapi.MouseScopeWidget,
|
||||
}
|
||||
}
|
||||
|
||||
// split splits the available area into label and text input areas according to
|
||||
// configuration. The returned labelAr might be image.ZR if no label was
|
||||
// configured.
|
||||
func split(cvsAr image.Rectangle, label string, widthPerc *int) (labelAr, textAr image.Rectangle, err error) {
|
||||
switch {
|
||||
case widthPerc != nil:
|
||||
splitP := 100 - *widthPerc
|
||||
labelAr, textAr, err := area.VSplit(cvsAr, splitP)
|
||||
if err != nil {
|
||||
return image.ZR, image.ZR, err
|
||||
}
|
||||
if len(label) == 0 {
|
||||
labelAr = image.ZR
|
||||
}
|
||||
return labelAr, textAr, nil
|
||||
|
||||
case len(label) > 0:
|
||||
cells := runewidth.StringWidth(label)
|
||||
labelAr, textAr, err := area.VSplitCells(cvsAr, cells)
|
||||
if err != nil {
|
||||
return image.ZR, image.ZR, err
|
||||
}
|
||||
return labelAr, textAr, nil
|
||||
|
||||
default:
|
||||
// Neither a label nor width percentage specified.
|
||||
return image.ZR, cvsAr, nil
|
||||
}
|
||||
}
|
||||
|
||||
// hideText returns the text with all runes replaced with hr.
|
||||
func hideText(text string, hr rune) string {
|
||||
var b strings.Builder
|
||||
|
||||
i := 0
|
||||
sw := runewidth.StringWidth(text)
|
||||
for _, r := range text {
|
||||
rw := runewidth.RuneWidth(r)
|
||||
switch {
|
||||
case i == 0 && r == '⇦':
|
||||
b.WriteRune(r)
|
||||
|
||||
case i == sw-1 && r == '⇨':
|
||||
b.WriteRune(r)
|
||||
|
||||
default:
|
||||
b.WriteString(strings.Repeat(string(hr), rw))
|
||||
}
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
1832
widgets/textinput/textinput_test.go
Normal file
1832
widgets/textinput/textinput_test.go
Normal file
File diff suppressed because it is too large
Load Diff
221
widgets/textinput/textinputdemo/textinputdemo.go
Normal file
221
widgets/textinput/textinputdemo/textinputdemo.go
Normal file
@ -0,0 +1,221 @@
|
||||
// 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.
|
||||
|
||||
// Binary textinputdemo shows the functionality of a text input field.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mum4k/termdash"
|
||||
"github.com/mum4k/termdash/align"
|
||||
"github.com/mum4k/termdash/cell"
|
||||
"github.com/mum4k/termdash/container"
|
||||
"github.com/mum4k/termdash/container/grid"
|
||||
"github.com/mum4k/termdash/keyboard"
|
||||
"github.com/mum4k/termdash/linestyle"
|
||||
"github.com/mum4k/termdash/terminal/termbox"
|
||||
"github.com/mum4k/termdash/widgets/button"
|
||||
"github.com/mum4k/termdash/widgets/segmentdisplay"
|
||||
"github.com/mum4k/termdash/widgets/textinput"
|
||||
)
|
||||
|
||||
// rotate returns a new slice with inputs rotated by step.
|
||||
// I.e. for a step of one:
|
||||
// inputs[0] -> inputs[len(inputs)-1]
|
||||
// inputs[1] -> inputs[0]
|
||||
// And so on.
|
||||
func rotate(inputs []rune, step int) []rune {
|
||||
return append(inputs[step:], inputs[:step]...)
|
||||
}
|
||||
|
||||
// textState creates a rotated state for the text we are displaying.
|
||||
func textState(text string, capacity, step int) []rune {
|
||||
if capacity == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var state []rune
|
||||
for i := 0; i < capacity; i++ {
|
||||
state = append(state, ' ')
|
||||
}
|
||||
state = append(state, []rune(text)...)
|
||||
step = step % len(state)
|
||||
return rotate(state, step)
|
||||
}
|
||||
|
||||
// rollText rolls a text across the segment display.
|
||||
// Exists when the context expires.
|
||||
func rollText(ctx context.Context, sd *segmentdisplay.SegmentDisplay, updateText <-chan string) {
|
||||
colors := []cell.Color{
|
||||
cell.ColorBlue,
|
||||
cell.ColorRed,
|
||||
cell.ColorYellow,
|
||||
cell.ColorBlue,
|
||||
cell.ColorGreen,
|
||||
cell.ColorRed,
|
||||
cell.ColorGreen,
|
||||
cell.ColorRed,
|
||||
}
|
||||
|
||||
text := "Termdash"
|
||||
step := 0
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
state := textState(text, sd.Capacity(), step)
|
||||
var chunks []*segmentdisplay.TextChunk
|
||||
for i := 0; i < sd.Capacity(); i++ {
|
||||
if i >= len(state) {
|
||||
break
|
||||
}
|
||||
|
||||
color := colors[i%len(colors)]
|
||||
chunks = append(chunks, segmentdisplay.NewChunk(
|
||||
string(state[i]),
|
||||
segmentdisplay.WriteCellOpts(cell.FgColor(color)),
|
||||
))
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := sd.Write(chunks); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
step++
|
||||
|
||||
case t := <-updateText:
|
||||
text = t
|
||||
sd.Reset()
|
||||
step = 0
|
||||
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
t, err := termbox.New()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer t.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
rollingSD, err := segmentdisplay.New(
|
||||
segmentdisplay.MaximizeSegmentHeight(),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
updateText := make(chan string)
|
||||
go rollText(ctx, rollingSD, updateText)
|
||||
|
||||
input, err := textinput.New(
|
||||
textinput.Label("New text:", cell.FgColor(cell.ColorBlue)),
|
||||
textinput.MaxWidthCells(20),
|
||||
textinput.Border(linestyle.Light),
|
||||
textinput.PlaceHolder("Enter any text"),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
submitB, err := button.New("Submit", func() error {
|
||||
updateText <- input.ReadAndClear()
|
||||
return nil
|
||||
},
|
||||
button.GlobalKey(keyboard.KeyEnter),
|
||||
button.FillColor(cell.ColorNumber(220)),
|
||||
)
|
||||
clearB, err := button.New("Clear", func() error {
|
||||
input.ReadAndClear()
|
||||
updateText <- ""
|
||||
return nil
|
||||
},
|
||||
button.WidthFor("Submit"),
|
||||
button.FillColor(cell.ColorNumber(220)),
|
||||
)
|
||||
quitB, err := button.New("Quit", func() error {
|
||||
cancel()
|
||||
return nil
|
||||
},
|
||||
button.WidthFor("Submit"),
|
||||
button.FillColor(cell.ColorNumber(196)),
|
||||
)
|
||||
|
||||
builder := grid.New()
|
||||
builder.Add(
|
||||
grid.RowHeightPerc(40,
|
||||
grid.Widget(
|
||||
rollingSD,
|
||||
),
|
||||
),
|
||||
)
|
||||
builder.Add(
|
||||
grid.RowHeightPerc(20,
|
||||
grid.Widget(
|
||||
input,
|
||||
container.AlignHorizontal(align.HorizontalCenter),
|
||||
container.AlignVertical(align.VerticalBottom),
|
||||
container.MarginBottom(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
builder.Add(
|
||||
grid.RowHeightPerc(40,
|
||||
grid.ColWidthPerc(20),
|
||||
grid.ColWidthPerc(20,
|
||||
grid.Widget(
|
||||
submitB,
|
||||
container.AlignVertical(align.VerticalTop),
|
||||
container.AlignHorizontal(align.HorizontalRight),
|
||||
),
|
||||
),
|
||||
grid.ColWidthPerc(20,
|
||||
grid.Widget(
|
||||
clearB,
|
||||
container.AlignVertical(align.VerticalTop),
|
||||
container.AlignHorizontal(align.HorizontalCenter),
|
||||
),
|
||||
),
|
||||
grid.ColWidthPerc(20,
|
||||
grid.Widget(
|
||||
quitB,
|
||||
container.AlignVertical(align.VerticalTop),
|
||||
container.AlignHorizontal(align.HorizontalLeft),
|
||||
),
|
||||
),
|
||||
grid.ColWidthPerc(20),
|
||||
),
|
||||
)
|
||||
|
||||
gridOpts, err := builder.Build()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c, err := container.New(t, gridOpts...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := termdash.Run(ctx, t, c, termdash.RedrawInterval(500*time.Millisecond)); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user