1
0
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:
Jakub Sobon 2019-04-27 02:58:23 -04:00 committed by GitHub
commit eacd178c7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 5460 additions and 294 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

View 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()
}

File diff suppressed because it is too large Load Diff

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