diff --git a/CHANGELOG.md b/CHANGELOG.md index 32958af..12e42ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The Button widget. - A function that draws text vertically. - The LineChart widget can display X axis labels in vertical orientation. - The LineChart widget allows the user to specify a custom scale for the Y @@ -34,10 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 subscriber. - The infrastructure now throttles event driven screen redraw rather than redrawing for each input event. -- Widgets can now specify the scope at which they want to receive keyboard - events, i.e. KeyScopeNone for no events, KeyScopeFocused to receive events - only if the parent container is focused and KeyScopeGlobal to receive all - keyboard events. +- Widgets can now specify the scope at which they want to receive keyboard and + mouse events. #### Breaking API changes @@ -55,8 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 distribution system. This shouldn't affect users as the removed methods aren't needed by container users. - The widgetapi.Options struct now uses an enum instead of a boolean when - widget specifies if it wants keyboard events. This affects development of new - widgets. + widget specifies if it wants keyboard or mouse events. This only impacts + development of new widgets. ### Fixed diff --git a/README.md b/README.md index 4202035..6b72b34 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # termdash -[termdashdemo](termdashdemo/termdashdemo.go) +[termdashdemo](termdashdemo/termdashdemo.go) This project implements a cross-platform customizable terminal based dashboard. The feature set is inspired by the @@ -62,6 +62,18 @@ Project documentation is available in the [doc](doc/) directory. ## Implemented Widgets +### The Button + +Allows users to interact with the application, each button press runs a callback function. +Run the +[buttondemo](widgets/button/buttondemo/buttondemo.go). + +```go +go run github.com/mum4k/termdash/widgets/button/buttondemo/buttondemo.go +``` + +[buttondemo](widgets/button/buttondemo/buttondemo.go) + ### The Gauge Displays the progress of an operation. Run the diff --git a/canvas/braille/braille_test.go b/canvas/braille/braille_test.go index b32fe97..4435c6a 100644 --- a/canvas/braille/braille_test.go +++ b/canvas/braille/braille_test.go @@ -295,7 +295,7 @@ func TestBraille(t *testing.T) { wantErr: true, }, { - desc: "SetCellOptions sets the cell options in full area", + desc: "SetAreaCellOptions sets the cell options in full area", ar: image.Rect(0, 0, 1, 1), pixelOps: func(c *Canvas) error { return c.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) @@ -315,7 +315,7 @@ func TestBraille(t *testing.T) { }, }, { - desc: "SetCellOptions sets the cell options in a sub-area", + desc: "SetAreaCellOptions sets the cell options in a sub-area", ar: image.Rect(0, 0, 3, 3), pixelOps: func(c *Canvas) error { return c.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) diff --git a/canvas/canvas.go b/canvas/canvas.go index 5f02023..f7bf0ae 100644 --- a/canvas/canvas.go +++ b/canvas/canvas.go @@ -21,6 +21,7 @@ import ( "github.com/mum4k/termdash/area" "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/cell/runewidth" "github.com/mum4k/termdash/terminalapi" ) @@ -94,6 +95,72 @@ func (c *Canvas) Cell(p image.Point) (*cell.Cell, error) { return c.buffer[p.X][p.Y].Copy(), nil } +// SetCellOpts sets options on the specified cell of the canvas without +// modifying the content of the cell. +// Sets the default cell options if no options are provided. +// This method is idempotent. +func (c *Canvas) SetCellOpts(p image.Point, opts ...cell.Option) error { + curCell, err := c.Cell(p) + if err != nil { + return err + } + + if len(opts) == 0 { + // Set the default options. + opts = []cell.Option{ + cell.FgColor(cell.ColorDefault), + cell.BgColor(cell.ColorDefault), + } + } + if _, err := c.SetCell(p, curCell.Rune, opts...); err != nil { + return err + } + return nil +} + +// SetAreaCells is like SetCell, but sets the specified rune and options on all +// the cells within the provided area. +// This method is idempotent. +func (c *Canvas) SetAreaCells(cellArea image.Rectangle, r rune, opts ...cell.Option) error { + haveArea := c.Area() + if !cellArea.In(haveArea) { + return fmt.Errorf("unable to set cell runes in area %v, it must fit inside the available cell area is %v", cellArea, haveArea) + } + + rw := runewidth.RuneWidth(r) + for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ { + for col := cellArea.Min.X; col < cellArea.Max.X; { + p := image.Point{col, row} + if col+rw > cellArea.Max.X { + break + } + cells, err := c.SetCell(p, r, opts...) + if err != nil { + return err + } + col += cells + } + } + return nil +} + +// SetAreaCellOpts is like SetCellOpts, but sets the specified options on all +// the cells within the provided area. +func (c *Canvas) SetAreaCellOpts(cellArea image.Rectangle, opts ...cell.Option) error { + haveArea := c.Area() + if !cellArea.In(haveArea) { + return fmt.Errorf("unable to set cell options in area %v, it must fit inside the available cell area is %v", cellArea, haveArea) + } + for col := cellArea.Min.X; col < cellArea.Max.X; col++ { + for row := cellArea.Min.Y; row < cellArea.Max.Y; row++ { + if err := c.SetCellOpts(image.Point{col, row}, opts...); err != nil { + return err + } + } + } + return nil +} + // setCellFunc is a function that sets cell content on a terminal or a canvas. type setCellFunc func(image.Point, rune, ...cell.Option) error diff --git a/canvas/canvas_test.go b/canvas/canvas_test.go index 071ec93..be44f53 100644 --- a/canvas/canvas_test.go +++ b/canvas/canvas_test.go @@ -100,6 +100,445 @@ func TestNew(t *testing.T) { } } +func TestCanvas(t *testing.T) { + tests := []struct { + desc string + canvas image.Rectangle + ops func(*Canvas) error + want func(size image.Point) (*faketerm.Terminal, error) + wantErr bool + }{ + { + desc: "SetCellOpts fails on a point outside of the canvas", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + return cvs.SetCellOpts(image.Point{1, 1}) + }, + wantErr: true, + }, + { + desc: "SetCellOpts sets options on a cell with no options", + canvas: image.Rect(0, 0, 2, 2), + ops: func(cvs *Canvas) error { + return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + c, err := cvs.Cell(image.Point{0, 1}) + if err != nil { + return nil, err + } + if _, err := cvs.SetCell(image.Point{0, 1}, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetCellOpts preserves cell rune", + canvas: image.Rect(0, 0, 2, 2), + ops: func(cvs *Canvas) error { + if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil { + return err + } + return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetCellOpts overwrites options set previously", + canvas: image.Rect(0, 0, 2, 2), + ops: func(cvs *Canvas) error { + if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return err + } + return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorGreen), cell.BgColor(cell.ColorYellow)); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetCellOpts sets default options when no options provided", + canvas: image.Rect(0, 0, 2, 2), + ops: func(cvs *Canvas) error { + if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return err + } + return cvs.SetCellOpts(image.Point{0, 1}) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetCellOpts is idempotent", + canvas: image.Rect(0, 0, 2, 2), + ops: func(cvs *Canvas) error { + if _, err := cvs.SetCell(image.Point{0, 1}, 'X'); err != nil { + return err + } + if err := cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return err + } + return cvs.SetCellOpts(image.Point{0, 1}, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 1}, 'X', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCellOpts fails on area too large", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + wantErr: true, + }, + { + desc: "SetAreaCellOpts sets options in the full canvas", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCellOpts(image.Rect(0, 0, 1, 1), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + for _, p := range []image.Point{ + {0, 0}, + } { + c, err := cvs.Cell(p) + if err != nil { + return nil, err + } + if _, err := cvs.SetCell(p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return nil, err + } + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCellOpts sets options in a sub-area", + canvas: image.Rect(0, 0, 3, 3), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCellOpts(image.Rect(0, 0, 2, 2), cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + for _, p := range []image.Point{ + {0, 0}, + {0, 1}, + {1, 0}, + {1, 1}, + } { + c, err := cvs.Cell(p) + if err != nil { + return nil, err + } + if _, err := cvs.SetCell(p, c.Rune, cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return nil, err + } + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCells sets cells in the full canvas", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r') + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 0}, 'r'); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCells is idempotent", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + if err := cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r'); err != nil { + return err + } + return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r') + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 0}, 'r'); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCells fails on area too large", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + wantErr: true, + }, + { + desc: "SetAreaCells sets cell options", + canvas: image.Rect(0, 0, 1, 1), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCells(image.Rect(0, 0, 1, 1), 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)) + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + if _, err := cvs.SetCell(image.Point{0, 0}, 'r', cell.FgColor(cell.ColorRed), cell.BgColor(cell.ColorBlue)); err != nil { + return nil, err + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCells sets cell in a sub-area", + canvas: image.Rect(0, 0, 3, 3), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), 'p') + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + for _, p := range []image.Point{ + {0, 0}, + {0, 1}, + {1, 0}, + {1, 1}, + } { + if _, err := cvs.SetCell(p, 'p'); err != nil { + return nil, err + } + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCells sets full-width runes that fit", + canvas: image.Rect(0, 0, 3, 3), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCells(image.Rect(0, 0, 2, 2), '世') + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + for _, p := range []image.Point{ + {0, 0}, + {0, 1}, + } { + if _, err := cvs.SetCell(p, '世'); err != nil { + return nil, err + } + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + { + desc: "SetAreaCells sets full-width runes that will leave a gap at the end of each row", + canvas: image.Rect(0, 0, 3, 3), + ops: func(cvs *Canvas) error { + return cvs.SetAreaCells(image.Rect(0, 0, 3, 3), '世') + }, + want: func(size image.Point) (*faketerm.Terminal, error) { + ft := faketerm.MustNew(size) + cvs, err := New(ft.Area()) + if err != nil { + return nil, err + } + + for _, p := range []image.Point{ + {0, 0}, + {0, 1}, + {0, 2}, + } { + if _, err := cvs.SetCell(p, '世'); err != nil { + return nil, err + } + } + + if err := cvs.Apply(ft); err != nil { + return nil, err + } + return ft, nil + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + cvs, err := New(tc.canvas) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + + if tc.ops != nil { + err := tc.ops(cvs) + if (err != nil) != tc.wantErr { + t.Errorf("tc.ops => unexpected error: %v, wantErr: %v", err, tc.wantErr) + } + if err != nil { + return + } + } + + size := cvs.Size() + got, err := faketerm.New(size) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + if err := cvs.Apply(got); err != nil { + t.Fatalf("cvs.Apply => %v", err) + } + + var want *faketerm.Terminal + if tc.want != nil { + want, err = tc.want(size) + if err != nil { + t.Fatalf("tc.want => unexpected error: %v", err) + } + } else { + w, err := faketerm.New(size) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + want = w + } + + if diff := faketerm.Diff(want, got); diff != "" { + t.Errorf("cvs.SetCellOpts => %v", diff) + } + }) + } +} + func TestSetCellAndApply(t *testing.T) { tests := []struct { desc string diff --git a/canvas/testcanvas/testcanvas.go b/canvas/testcanvas/testcanvas.go index 166fddb..0439640 100644 --- a/canvas/testcanvas/testcanvas.go +++ b/canvas/testcanvas/testcanvas.go @@ -51,6 +51,13 @@ func MustSetCell(c *canvas.Canvas, p image.Point, r rune, opts ...cell.Option) i return cells } +// MustSetAreaCells sets the cells in the area or panics. +func MustSetAreaCells(c *canvas.Canvas, cellArea image.Rectangle, r rune, opts ...cell.Option) { + if err := c.SetAreaCells(cellArea, r, opts...); err != nil { + panic(fmt.Sprintf("canvas.SetAreaCells => unexpected error: %v", err)) + } +} + // MustCell returns the cell or panics. func MustCell(c *canvas.Canvas, p image.Point) *cell.Cell { cell, err := c.Cell(p) diff --git a/container/container.go b/container/container.go index 4d5e3d1..4d3e76c 100644 --- a/container/container.go +++ b/container/container.go @@ -200,26 +200,18 @@ func (c *Container) updateFocus(m *terminalapi.Mouse) { } // keyboardToWidget forwards the keyboard event to the widget unconditionally. -func (c *Container) keyboardToWidget(k *terminalapi.Keyboard) error { - c.mu.Lock() - defer c.mu.Unlock() - return c.opts.widget.Keyboard(k) -} - -// keyboardToFocusedWidget forwards the keyboard event to the widget if its -// container is focused. -func (c *Container) keyboardToFocusedWidget(k *terminalapi.Keyboard) error { +func (c *Container) keyboardToWidget(k *terminalapi.Keyboard, scope widgetapi.KeyScope) error { c.mu.Lock() defer c.mu.Unlock() - if !c.focusTracker.isActive(c) { + if scope == widgetapi.KeyScopeFocused && !c.focusTracker.isActive(c) { return nil } return c.opts.widget.Keyboard(k) } // mouseToWidget forwards the mouse event to the widget. -func (c *Container) mouseToWidget(m *terminalapi.Mouse) error { +func (c *Container) mouseToWidget(m *terminalapi.Mouse, scope widgetapi.MouseScope) error { c.mu.Lock() defer c.mu.Unlock() @@ -229,7 +221,7 @@ func (c *Container) mouseToWidget(m *terminalapi.Mouse) error { } // Ignore clicks falling outside of the container. - if !m.Position.In(target.usable()) { + if scope != widgetapi.MouseScopeGlobal && !m.Position.In(c.area) { return nil } @@ -238,7 +230,7 @@ func (c *Container) mouseToWidget(m *terminalapi.Mouse) error { if err != nil { return err } - if !m.Position.In(wa) { + if scope == widgetapi.MouseScopeWidget && !m.Position.In(wa) { return nil } @@ -246,9 +238,17 @@ func (c *Container) mouseToWidget(m *terminalapi.Mouse) error { // based, even though the widget might not be in the top left corner on the // terminal. offset := wa.Min - wm := &terminalapi.Mouse{ - Position: m.Position.Sub(offset), - Button: m.Button, + var wm *terminalapi.Mouse + if m.Position.In(wa) { + wm = &terminalapi.Mouse{ + Position: m.Position.Sub(offset), + Button: m.Button, + } + } else { + wm = &terminalapi.Mouse{ + Position: image.Point{-1, -1}, + Button: m.Button, + } } return c.opts.widget.Mouse(wm) } @@ -274,28 +274,25 @@ func (c *Container) Subscribe(eds *event.DistributionSystem) { preOrder(root, &errStr, visitFunc(func(c *Container) error { if c.hasWidget() { wOpt := c.opts.widget.Options() - switch wOpt.WantKeyboard { + switch scope := wOpt.WantKeyboard; scope { case widgetapi.KeyScopeNone: // Widget doesn't want any keyboard events. - case widgetapi.KeyScopeFocused: + default: eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) { - if err := c.keyboardToFocusedWidget(ev.(*terminalapi.Keyboard)); err != nil { - eds.Event(terminalapi.NewErrorf("failed to send keyboard event %v to widget %T: %v", ev, c.opts.widget, err)) - } - }, event.MaxRepetitive(maxReps)) - - case widgetapi.KeyScopeGlobal: - eds.Subscribe([]terminalapi.Event{&terminalapi.Keyboard{}}, func(ev terminalapi.Event) { - if err := c.keyboardToWidget(ev.(*terminalapi.Keyboard)); err != nil { + if err := c.keyboardToWidget(ev.(*terminalapi.Keyboard), scope); err != nil { eds.Event(terminalapi.NewErrorf("failed to send global keyboard event %v to widget %T: %v", ev, c.opts.widget, err)) } }, event.MaxRepetitive(maxReps)) } - if wOpt.WantMouse { + switch scope := wOpt.WantMouse; scope { + case widgetapi.MouseScopeNone: + // Widget doesn't want any mouse events. + + default: eds.Subscribe([]terminalapi.Event{&terminalapi.Mouse{}}, func(ev terminalapi.Event) { - if err := c.mouseToWidget(ev.(*terminalapi.Mouse)); err != nil { + if err := c.mouseToWidget(ev.(*terminalapi.Mouse), scope); err != nil { eds.Event(terminalapi.NewErrorf("failed to send mouse event %v to widget %T: %v", ev, c.opts.widget, err)) } }, event.MaxRepetitive(maxReps)) diff --git a/container/container_test.go b/container/container_test.go index 52b6241..66c3580 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -841,7 +841,7 @@ func TestMouse(t *testing.T) { container: func(ft *faketerm.Terminal) (*Container, error) { return New( ft, - PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})), + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})), ) }, events: []terminalapi.Event{ @@ -883,15 +883,15 @@ func TestMouse(t *testing.T) { ft, SplitVertical( Left( - PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})), + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})), ), Right( SplitHorizontal( Top( - PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})), + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})), ), Bottom( - PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})), + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})), ), ), ), @@ -913,7 +913,7 @@ func TestMouse(t *testing.T) { fakewidget.MustDraw( ft, testcanvas.MustNew(image.Rect(25, 10, 50, 20)), - widgetapi.Options{WantMouse: true}, + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, &terminalapi.Keyboard{}, ) @@ -921,7 +921,7 @@ func TestMouse(t *testing.T) { fakewidget.MustDraw( ft, testcanvas.MustNew(image.Rect(25, 0, 50, 10)), - widgetapi.Options{WantMouse: true}, + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonLeft}, &terminalapi.Mouse{Position: image.Point{24, 9}, Button: mouse.ButtonRelease}, ) @@ -935,7 +935,7 @@ func TestMouse(t *testing.T) { container: func(ft *faketerm.Terminal) (*Container, error) { return New( ft, - PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: false})), + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeNone})), ) }, events: []terminalapi.Event{ @@ -954,14 +954,14 @@ func TestMouse(t *testing.T) { wantProcessed: 1, }, { - desc: "event not forwarded if it falls on the container's border", + desc: "MouseScopeWidget, event not forwarded if it falls on the container's border", termSize: image.Point{20, 20}, container: func(ft *faketerm.Terminal) (*Container, error) { return New( ft, Border(draw.LineStyleLight), PlaceWidget( - fakewidget.New(widgetapi.Options{WantMouse: true}), + fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}), ), ) }, @@ -989,14 +989,86 @@ func TestMouse(t *testing.T) { wantProcessed: 2, }, { - desc: "event not forwarded if it falls outside of widget's canvas", + desc: "MouseScopeContainer, event forwarded if it falls on the container's border", + termSize: image.Point{21, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + Border(draw.LineStyleLight), + PlaceWidget( + fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeContainer}), + ), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustBorder( + cvs, + ft.Area(), + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)), + ) + testcanvas.MustApply(cvs, ft) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(1, 1, 20, 19)), + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, + &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "MouseScopeGlobal, event forwarded if it falls on the container's border", + termSize: image.Point{21, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + Border(draw.LineStyleLight), + PlaceWidget( + fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeGlobal}), + ), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + cvs := testcanvas.MustNew(ft.Area()) + testdraw.MustBorder( + cvs, + ft.Area(), + draw.BorderCellOpts(cell.FgColor(cell.ColorYellow)), + ) + testcanvas.MustApply(cvs, ft) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(1, 1, 20, 19)), + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, + &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "MouseScopeWidget event not forwarded if it falls outside of widget's canvas", termSize: image.Point{20, 20}, container: func(ft *faketerm.Terminal) (*Container, error) { return New( ft, PlaceWidget( fakewidget.New(widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, Ratio: image.Point{2, 1}, }), ), @@ -1020,14 +1092,187 @@ func TestMouse(t *testing.T) { wantProcessed: 2, }, { - desc: "mouse poisition adjusted relative to widget's canvas, vertical offset", + desc: "MouseScopeContainer event forwarded if it falls outside of widget's canvas", termSize: image.Point{20, 20}, container: func(ft *faketerm.Terminal) (*Container, error) { return New( ft, PlaceWidget( fakewidget.New(widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeContainer, + Ratio: image.Point{2, 1}, + }), + ), + AlignVertical(align.VerticalMiddle), + AlignHorizontal(align.HorizontalCenter), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(0, 5, 20, 15)), + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, + &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "MouseScopeGlobal event forwarded if it falls outside of widget's canvas", + termSize: image.Point{20, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + PlaceWidget( + fakewidget.New(widgetapi.Options{ + WantMouse: widgetapi.MouseScopeGlobal, + Ratio: image.Point{2, 1}, + }), + ), + AlignVertical(align.VerticalMiddle), + AlignHorizontal(align.HorizontalCenter), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(0, 5, 20, 15)), + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, + &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "MouseScopeWidget event not forwarded if it falls to another container", + termSize: image.Point{20, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + SplitHorizontal( + Top(), + Bottom( + PlaceWidget( + fakewidget.New(widgetapi.Options{ + WantMouse: widgetapi.MouseScopeWidget, + Ratio: image.Point{2, 1}, + }), + ), + AlignVertical(align.VerticalMiddle), + AlignHorizontal(align.HorizontalCenter), + ), + ), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(0, 10, 20, 20)), + widgetapi.Options{}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "MouseScopeContainer event not forwarded if it falls to another container", + termSize: image.Point{20, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + SplitHorizontal( + Top(), + Bottom( + PlaceWidget( + fakewidget.New(widgetapi.Options{ + WantMouse: widgetapi.MouseScopeContainer, + Ratio: image.Point{2, 1}, + }), + ), + AlignVertical(align.VerticalMiddle), + AlignHorizontal(align.HorizontalCenter), + ), + ), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(0, 10, 20, 20)), + widgetapi.Options{}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "MouseScopeGlobal event forwarded if it falls to another container", + termSize: image.Point{20, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + SplitHorizontal( + Top(), + Bottom( + PlaceWidget( + fakewidget.New(widgetapi.Options{ + WantMouse: widgetapi.MouseScopeGlobal, + Ratio: image.Point{2, 1}, + }), + ), + AlignVertical(align.VerticalMiddle), + AlignHorizontal(align.HorizontalCenter), + ), + ), + ) + }, + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + + fakewidget.MustDraw( + ft, + testcanvas.MustNew(image.Rect(0, 10, 20, 20)), + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, + &terminalapi.Mouse{Position: image.Point{-1, -1}, Button: mouse.ButtonLeft}, + ) + return ft + }, + wantProcessed: 2, + }, + { + desc: "mouse position adjusted relative to widget's canvas, vertical offset", + termSize: image.Point{20, 20}, + container: func(ft *faketerm.Terminal) (*Container, error) { + return New( + ft, + PlaceWidget( + fakewidget.New(widgetapi.Options{ + WantMouse: widgetapi.MouseScopeWidget, Ratio: image.Point{2, 1}, }), ), @@ -1044,7 +1289,7 @@ func TestMouse(t *testing.T) { fakewidget.MustDraw( ft, testcanvas.MustNew(image.Rect(0, 5, 20, 15)), - widgetapi.Options{WantMouse: true}, + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, ) return ft @@ -1059,7 +1304,7 @@ func TestMouse(t *testing.T) { ft, PlaceWidget( fakewidget.New(widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, Ratio: image.Point{9, 10}, }), ), @@ -1076,7 +1321,7 @@ func TestMouse(t *testing.T) { fakewidget.MustDraw( ft, testcanvas.MustNew(image.Rect(6, 0, 24, 20)), - widgetapi.Options{WantMouse: true}, + widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget}, &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, ) return ft @@ -1089,7 +1334,7 @@ func TestMouse(t *testing.T) { container: func(ft *faketerm.Terminal) (*Container, error) { return New( ft, - PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: true})), + PlaceWidget(fakewidget.New(widgetapi.Options{WantMouse: widgetapi.MouseScopeWidget})), ) }, events: []terminalapi.Event{ diff --git a/images/buttondemo.gif b/images/buttondemo.gif new file mode 100644 index 0000000..c621f28 Binary files /dev/null and b/images/buttondemo.gif differ diff --git a/images/termdashdemo_0_6_0.gif b/images/termdashdemo_0_6_0.gif deleted file mode 100644 index df63a8f..0000000 Binary files a/images/termdashdemo_0_6_0.gif and /dev/null differ diff --git a/images/termdashdemo_0_7_0.gif b/images/termdashdemo_0_7_0.gif new file mode 100644 index 0000000..b49214b Binary files /dev/null and b/images/termdashdemo_0_7_0.gif differ diff --git a/mouse/button/button.go b/mouse/button/button.go index 3ced57e..5362f39 100644 --- a/mouse/button/button.go +++ b/mouse/button/button.go @@ -90,6 +90,12 @@ func (fsm *FSM) Event(m *terminalapi.Mouse) (bool, State) { return clicked, bs } +// UpdateArea informs FSM of an area change. +// This method is idempotent. +func (fsm *FSM) UpdateArea(area image.Rectangle) { + fsm.area = area +} + // stateFn is a single state in the state machine. // Returns bool indicating if a click happened, the state of the button and the // next state of the FSM. diff --git a/mouse/button/button_test.go b/mouse/button/button_test.go index 1b6cd1f..16e230d 100644 --- a/mouse/button/button_test.go +++ b/mouse/button/button_test.go @@ -25,6 +25,9 @@ import ( // eventTestCase is one mouse event and the output expectation. type eventTestCase struct { + // area if specified, will be provided to UpdateArea *before* processing the event. + area *image.Rectangle + // event is the mouse event to send. event *terminalapi.Mouse @@ -59,6 +62,69 @@ func TestFSM(t *testing.T) { }, }, }, + { + desc: "updates area so the clicks falls outside", + button: mouse.ButtonLeft, + area: image.Rect(0, 0, 1, 1), + eventCases: []*eventTestCase{ + { + area: func() *image.Rectangle { + ar := image.Rect(1, 1, 2, 2) + return &ar + }(), + event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + wantClick: false, + wantState: Up, + }, + { + event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + wantClick: false, + wantState: Up, + }, + }, + }, + { + desc: "updates area before release, so the release falls outside", + button: mouse.ButtonLeft, + area: image.Rect(0, 0, 1, 1), + eventCases: []*eventTestCase{ + { + event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + wantClick: false, + wantState: Down, + }, + { + area: func() *image.Rectangle { + ar := image.Rect(1, 1, 2, 2) + return &ar + }(), + event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + wantClick: false, + wantState: Up, + }, + }, + }, + { + desc: "increased area makes the release count", + button: mouse.ButtonLeft, + area: image.Rect(0, 0, 1, 1), + eventCases: []*eventTestCase{ + { + event: &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + wantClick: false, + wantState: Down, + }, + { + area: func() *image.Rectangle { + ar := image.Rect(0, 0, 2, 2) + return &ar + }(), + event: &terminalapi.Mouse{Position: image.Point{1, 1}, Button: mouse.ButtonRelease}, + wantClick: true, + wantState: Up, + }, + }, + }, { desc: "tracks single right button click", button: mouse.ButtonRight, @@ -234,6 +300,10 @@ func TestFSM(t *testing.T) { t.Run(fmt.Sprintf(tc.desc), func(t *testing.T) { fsm := NewFSM(tc.button, tc.area) for _, etc := range tc.eventCases { + if etc.area != nil { + fsm.UpdateArea(*etc.area) + } + gotClick, gotState := fsm.Event(etc.event) t.Logf("Called fsm.Event(%v) => %v, %v", etc.event, gotClick, gotState) if gotClick != etc.wantClick || gotState != etc.wantState { diff --git a/termdash_test.go b/termdash_test.go index 2f8d38a..c302e27 100644 --- a/termdash_test.go +++ b/termdash_test.go @@ -49,7 +49,7 @@ func Example() { wOpts := widgetapi.Options{ MinimumSize: fakewidget.MinimumSize, WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, } // Create the container with two fake widgets. @@ -89,7 +89,7 @@ func Example_triggered() { wOpts := widgetapi.Options{ MinimumSize: fakewidget.MinimumSize, WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, } // Create the container with a widget. @@ -239,7 +239,7 @@ func TestRun(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, ) @@ -265,7 +265,7 @@ func TestRun(t *testing.T) { testcanvas.MustNew(ft.Area()), widgetapi.Options{ WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Keyboard{Key: keyboard.KeyEnter}, ) @@ -360,7 +360,7 @@ func TestRun(t *testing.T) { ft, testcanvas.MustNew(ft.Area()), widgetapi.Options{ - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonWheelUp}, ) @@ -394,7 +394,7 @@ func TestRun(t *testing.T) { got, container.PlaceWidget(fakewidget.New(widgetapi.Options{ WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, })), ) if err != nil { @@ -460,7 +460,7 @@ func TestController(t *testing.T) { testcanvas.MustNew(ft.Area()), widgetapi.Options{ WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, &terminalapi.Keyboard{Key: keyboard.KeyEnter}, ) @@ -580,7 +580,7 @@ func TestController(t *testing.T) { mi := fakewidget.New(widgetapi.Options{ WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }) cont, err := container.New( got, diff --git a/termdashdemo/termdashdemo.go b/termdashdemo/termdashdemo.go index f39da99..44c54dd 100644 --- a/termdashdemo/termdashdemo.go +++ b/termdashdemo/termdashdemo.go @@ -21,15 +21,18 @@ import ( "fmt" "math" "math/rand" + "sync" "time" "github.com/mum4k/termdash" + "github.com/mum4k/termdash/align" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" "github.com/mum4k/termdash/draw" "github.com/mum4k/termdash/terminal/termbox" "github.com/mum4k/termdash/terminalapi" "github.com/mum4k/termdash/widgets/barchart" + "github.com/mum4k/termdash/widgets/button" "github.com/mum4k/termdash/widgets/donut" "github.com/mum4k/termdash/widgets/gauge" "github.com/mum4k/termdash/widgets/linechart" @@ -128,10 +131,34 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, return nil, err } - sineLC, err := newSines(ctx) + leftB, rightB, sineLC, err := newSines(ctx) if err != nil { return nil, err } + lcAndButtons := []container.Option{ + container.SplitHorizontal( + container.Top( + container.Border(draw.LineStyleLight), + container.BorderTitle("Multiple series"), + container.BorderTitleAlignRight(), + container.PlaceWidget(sineLC), + ), + container.Bottom( + container.SplitVertical( + container.Left( + container.PlaceWidget(leftB), + container.AlignHorizontal(align.HorizontalRight), + ), + container.Right( + container.PlaceWidget(rightB), + container.AlignHorizontal(align.HorizontalLeft), + ), + ), + ), + container.SplitPercent(80), + ), + } + rightSide := []container.Option{ container.SplitHorizontal( container.Top( @@ -148,12 +175,7 @@ func layout(ctx context.Context, t terminalapi.Terminal) (*container.Container, container.BorderTitleAlignRight(), container.PlaceWidget(don), ), - container.Bottom( - container.Border(draw.LineStyleLight), - container.BorderTitle("Multiple series"), - container.BorderTitleAlignRight(), - container.PlaceWidget(sineLC), - ), + container.Bottom(lcAndButtons...), container.SplitPercent(30), ), ), @@ -422,23 +444,47 @@ func newBarChart(ctx context.Context) (*barchart.BarChart, error) { return bc, nil } -// newSines returns a line chart that displays multiple sine series. -func newSines(ctx context.Context) (*linechart.LineChart, error) { +// distance is a thread-safe int value used by the newSince method. +// Buttons write it and the line chart reads it. +type distance struct { + v int + mu sync.Mutex +} + +// add adds the provided value to the one stored. +func (d *distance) add(v int) { + d.mu.Lock() + defer d.mu.Unlock() + d.v += v +} + +// get returns the current value. +func (d *distance) get() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.v +} + +// newSines returns a line chart that displays multiple sine series and two buttons. +// The left button shifts the second series relative to the first series to +// the left and the right button shifts it to the right. +func newSines(ctx context.Context) (left, right *button.Button, lc *linechart.LineChart, err error) { var inputs []float64 for i := 0; i < 200; i++ { v := math.Sin(float64(i) / 100 * math.Pi) inputs = append(inputs, v) } - lc, err := linechart.New( + sineLc, err := linechart.New( linechart.AxesCellOpts(cell.FgColor(cell.ColorRed)), linechart.YLabelCellOpts(cell.FgColor(cell.ColorGreen)), linechart.XLabelCellOpts(cell.FgColor(cell.ColorGreen)), ) if err != nil { - return nil, err + return nil, nil, nil, err } step1 := 0 + secondDist := &distance{v: 100} go periodic(ctx, redrawInterval/3, func() error { step1 = (step1 + 1) % len(inputs) if err := lc.Series("first", rotateFloats(inputs, step1), @@ -447,10 +493,30 @@ func newSines(ctx context.Context) (*linechart.LineChart, error) { return err } - step2 := (step1 + 100) % len(inputs) + step2 := (step1 + secondDist.get()) % len(inputs) return lc.Series("second", rotateFloats(inputs, step2), linechart.SeriesCellOpts(cell.FgColor(cell.ColorWhite))) }) - return lc, nil + + // diff is the difference a single button press adds or removes to the + // second series. + const diff = 20 + leftB, err := button.New("(l)eft", func() error { + secondDist.add(diff) + return nil + }, + button.GlobalKey('l'), + button.WidthFor("(r)ight"), + button.FillColor(cell.ColorNumber(220)), + ) + + rightB, err := button.New("(r)ight", func() error { + secondDist.add(-diff) + return nil + }, + button.GlobalKey('r'), + button.FillColor(cell.ColorNumber(196)), + ) + return leftB, rightB, sineLc, nil } // rotateFloats returns a new slice with inputs rotated by step. diff --git a/widgetapi/widgetapi.go b/widgetapi/widgetapi.go index 4ad85e3..afa6c3b 100644 --- a/widgetapi/widgetapi.go +++ b/widgetapi/widgetapi.go @@ -55,6 +55,54 @@ const ( KeyScopeGlobal ) +// MouseScope indicates the scope at which the widget wants to receive mouse +// events. +type MouseScope int + +// String implements fmt.Stringer() +func (ms MouseScope) String() string { + if n, ok := mouseScopeNames[ms]; ok { + return n + } + return "MouseScopeUnknown" +} + +// mouseScopeNames maps MouseScope values to human readable names. +var mouseScopeNames = map[MouseScope]string{ + MouseScopeNone: "MouseScopeNone", + MouseScopeWidget: "MouseScopeWidget", + MouseScopeContainer: "MouseScopeContainer", + MouseScopeGlobal: "MouseScopeGlobal", +} + +const ( + // MouseScopeNone is used when the widget doesn't want to receive any mouse + // events. + MouseScopeNone MouseScope = iota + + // MouseScopeWidget is used when the widget only wants mouse events that + // fall onto its canvas. + // The position of these widgets is always relative to widget's canvas. + MouseScopeWidget + + // MouseScopeContainer is used when the widget only wants mouse events that + // fall onto its container. The area size of a container is always larger + // or equal to the one of the widget's canvas. So a widget selecting + // MouseScopeContainer will either receive the same or larger amount of + // events as compared to MouseScopeWidget. + // The position of mouse events that fall outside of widget's canvas is + // reset to image.Point{-1, -1}. + // The widgets are allowed to process the button event. + MouseScopeContainer + + // MouseScopeGlobal is used when the widget wants to receive all mouse + // events regardless on where on the terminal they land. + // The position of mouse events that fall outside of widget's canvas is + // reset to image.Point{-1, -1} and must not be used by the widgets. + // The widgets are allowed to process the button event. + MouseScopeGlobal +) + // Options contains registration options for a widget. // This is how the widget indicates its needs to the infrastructure. type Options struct { @@ -82,11 +130,13 @@ type Options struct { // forwarded to the widget. WantKeyboard KeyScope - // WantMouse allows a widget to request mouse events. - // If false, mouse events won't be forwarded to the widget. - // If true, the widget receives all mouse events whose coordinates fall - // within its canvas. - WantMouse bool + // WantMouse allows a widget to request mouse events and specify their + // desired scope. If set to MouseScopeNone, no mouse events are forwarded + // to the widget. + // Note that the widget is only able to see the position of the mouse event + // if it falls onto its canvas. See the documentation next to individual + // MouseScope values for details. + WantMouse MouseScope } // Widget is a single widget on the dashboard. diff --git a/widgets/barchart/barchart.go b/widgets/barchart/barchart.go index ae2787f..dc94941 100644 --- a/widgets/barchart/barchart.go +++ b/widgets/barchart/barchart.go @@ -270,7 +270,7 @@ func (bc *BarChart) Options() widgetapi.Options { return widgetapi.Options{ MinimumSize: bc.minSize(), WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/barchart/barchart_test.go b/widgets/barchart/barchart_test.go index ea892e9..4c36dc3 100644 --- a/widgets/barchart/barchart_test.go +++ b/widgets/barchart/barchart_test.go @@ -629,7 +629,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -642,7 +642,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -660,7 +660,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -678,7 +678,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{3, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -699,7 +699,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{8, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -720,7 +720,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{8, 2}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, } diff --git a/widgets/button/button.go b/widgets/button/button.go new file mode 100644 index 0000000..8c64a54 --- /dev/null +++ b/widgets/button/button.go @@ -0,0 +1,209 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package button implements an interactive widget that can be pressed to +// activate. +package button + +import ( + "errors" + "image" + "sync" + "time" + + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/mouse/button" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgetapi" +) + +// CallbackFn is the function called when the button is pressed. +// The callback function must be light-weight, ideally just storing a value and +// returning, since more button presses might occur. +// +// The callback function must be thread-safe as the mouse or keyboard events +// that press the button are processed in a separate goroutine. +// +// If the function returns an error, the widget will forward it back to the +// termdash infrastructure which causes a panic, unless the user provided a +// termdash.ErrorHandler. +type CallbackFn func() error + +// Button can be pressed using a mouse click or a configured keyboard key. +// +// Upon each press, the button invokes a callback provided by the user. +// +// Implements widgetapi.Widget. This object is thread-safe. +type Button struct { + // text in the text label displayed in the button. + text string + + // mouseFSM tracks left mouse clicks. + mouseFSM *button.FSM + // state is the current state of the button. + state button.State + + // keyTriggerTime is the last time the button was pressed using a keyboard + // key. It is nil if the button was triggered by a mouse event. + // Used to draw button presses on keyboard events, since termbox doesn't + // provide us with release events for keys. + keyTriggerTime *time.Time + + // callback gets called on each button press. + callback CallbackFn + + // mu protects the widget. + mu sync.Mutex + + // opts are the provided options. + opts *options +} + +// New returns a new Button that will display the provided text. +// Each press of the button will invoke the callback function. +func New(text string, cFn CallbackFn, opts ...Option) (*Button, error) { + if cFn == nil { + return nil, errors.New("the CallbackFn argument cannot be nil") + } + + opt := newOptions(text) + for _, o := range opts { + o.set(opt) + } + if err := opt.validate(); err != nil { + return nil, err + } + return &Button{ + text: text, + mouseFSM: button.NewFSM(mouse.ButtonLeft, image.ZR), + callback: cFn, + opts: opt, + }, nil +} + +// Vars to be replaced from tests. +var ( + // Runes to use in cells that contain the button. + // Changed from tests to provide readable test failures. + buttonRune = ' ' + // Runes to use in cells that contain the shadow. + // Changed from tests to provide readable test failures. + shadowRune = ' ' + + // timeSince is a function that calculates duration since some time. + timeSince = time.Since +) + +// Draw draws the Button widget onto the canvas. +// Implements widgetapi.Widget.Draw. +func (b *Button) Draw(cvs *canvas.Canvas) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.keyTriggerTime != nil { + since := timeSince(*b.keyTriggerTime) + if since > b.opts.keyUpDelay { + b.state = button.Up + } + } + + cvsAr := cvs.Area() + b.mouseFSM.UpdateArea(cvsAr) + + shadowAr := image.Rect(shadowWidth, shadowWidth, cvsAr.Dx(), cvsAr.Dy()) + if err := cvs.SetAreaCells(shadowAr, shadowRune, cell.BgColor(b.opts.shadowColor)); err != nil { + return err + } + + var buttonAr image.Rectangle + if b.state == button.Up { + buttonAr = image.Rect(0, 0, cvsAr.Dx()-shadowWidth, cvsAr.Dy()-shadowWidth) + } else { + buttonAr = shadowAr + } + + if err := cvs.SetAreaCells(buttonAr, buttonRune, cell.BgColor(b.opts.fillColor)); err != nil { + return err + } + + textAr := image.Rect(buttonAr.Min.X+1, buttonAr.Min.Y, buttonAr.Dx()-1, buttonAr.Max.Y) + start, err := align.Text(textAr, b.text, align.HorizontalCenter, align.VerticalMiddle) + if err != nil { + return err + } + if err := draw.Text(cvs, b.text, start, + draw.TextOverrunMode(draw.OverrunModeThreeDot), + draw.TextMaxX(buttonAr.Max.X), + draw.TextCellOpts(cell.FgColor(b.opts.textColor)), + ); err != nil { + return err + } + return nil +} + +// Keyboard processes keyboard events, acts as a button press on the configured +// Key. +// +// Implements widgetapi.Widget.Keyboard. +func (b *Button) Keyboard(k *terminalapi.Keyboard) error { + b.mu.Lock() + defer b.mu.Unlock() + + if k.Key == b.opts.key { + b.state = button.Down + now := time.Now().UTC() + b.keyTriggerTime = &now + return b.callback() + } + return nil +} + +// Mouse processes mouse events, acts as a button press if both the press and +// the release happen inside the button. +// +// Implements widgetapi.Widget.Mouse. +func (b *Button) Mouse(m *terminalapi.Mouse) error { + b.mu.Lock() + defer b.mu.Unlock() + + clicked, state := b.mouseFSM.Event(m) + b.state = state + b.keyTriggerTime = nil + + if clicked { + return b.callback() + } + return nil +} + +// shadowWidth is the width of the shadow under the button in cell. +const shadowWidth = 1 + +// Options implements widgetapi.Widget.Options. +func (b *Button) Options() widgetapi.Options { + // No need to lock, as the height and width get fixed when New is called. + + width := b.opts.width + shadowWidth + height := b.opts.height + shadowWidth + return widgetapi.Options{ + MinimumSize: image.Point{width, height}, + MaximumSize: image.Point{width, height}, + WantKeyboard: b.opts.keyScope, + WantMouse: widgetapi.MouseScopeGlobal, + } +} diff --git a/widgets/button/button_test.go b/widgets/button/button_test.go new file mode 100644 index 0000000..c4f92d1 --- /dev/null +++ b/widgets/button/button_test.go @@ -0,0 +1,846 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package button + +import ( + "errors" + "image" + "sync" + "testing" + "time" + + "github.com/kylelemons/godebug/pretty" + "github.com/mum4k/termdash/canvas" + "github.com/mum4k/termdash/canvas/testcanvas" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/draw/testdraw" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/mouse" + "github.com/mum4k/termdash/terminal/faketerm" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgetapi" +) + +// callbackTracker tracks whether callback was called. +type callbackTracker struct { + // wantErr when set to true, makes callback return an error. + wantErr bool + + // called asserts whether the callback was called. + called bool + + // count is the number of times the callback was called. + count int + + // mu protects the tracker. + mu sync.Mutex +} + +// callback is the callback function. +func (ct *callbackTracker) callback() error { + ct.mu.Lock() + defer ct.mu.Unlock() + + if ct.wantErr { + return errors.New("ct.wantErr set to true") + } + + ct.count++ + ct.called = true + return nil +} + +func TestButton(t *testing.T) { + tests := []struct { + desc string + text string + callback *callbackTracker + opts []Option + events []terminalapi.Event + canvas image.Rectangle + + // timeSince is used to replace time.Since for tests, leave nil to use + // the original. + timeSince func(time.Time) time.Duration + + want func(size image.Point) *faketerm.Terminal + wantCallback *callbackTracker + wantNewErr bool + wantDrawErr bool + wantCallbackErr bool + }{ + { + desc: "New fails with nil callback", + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with negative keyUpDelay", + callback: &callbackTracker{}, + opts: []Option{ + KeyUpDelay(-1 * time.Second), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with zero Height", + callback: &callbackTracker{}, + opts: []Option{ + Height(0), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "New fails with zero Width", + callback: &callbackTracker{}, + opts: []Option{ + Width(0), + }, + canvas: image.Rect(0, 0, 1, 1), + wantNewErr: true, + }, + { + desc: "draw fails on canvas too small", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 1, 1), + wantDrawErr: true, + }, + { + desc: "draws button in up state", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "draws button in down state due to a mouse event", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "mouse triggered the callback", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "draws button in down state due to a keyboard event, callback triggered", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event ignored when no key specified", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "keyboard event triggers the button, trigger time didn't expire so button is down", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{2, 2}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event triggers the button, custom trigger time expired so button is up", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + KeyUpDelay(100 * time.Millisecond), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 1, + }, + }, + { + desc: "keyboard event triggers the button multiple times", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + KeyUpDelay(100 * time.Millisecond), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 3, + }, + }, + { + desc: "mouse event triggers the button multiple times", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{ + called: true, + count: 2, + }, + }, + { + desc: "the callback returns an error after a mouse event", + callback: &callbackTracker{ + wantErr: true, + }, + text: "hello", + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonLeft}, + &terminalapi.Mouse{Position: image.Point{0, 0}, Button: mouse.ButtonRelease}, + }, + wantCallbackErr: true, + }, + { + desc: "the callback returns an error after a keyboard event", + callback: &callbackTracker{ + wantErr: true, + }, + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + KeyUpDelay(100 * time.Millisecond), + }, + timeSince: func(time.Time) time.Duration { + return 200 * time.Millisecond + }, + canvas: image.Rect(0, 0, 8, 4), + events: []terminalapi.Event{ + &terminalapi.Keyboard{Key: keyboard.KeyEnter}, + }, + wantCallbackErr: true, + }, + { + desc: "draws button with custom height (infra gives smaller canvas)", + callback: &callbackTracker{}, + text: "hello", + canvas: image.Rect(0, 0, 8, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 2), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 1), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 0}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "button width adjusts to width (infra gives smaller canvas)", + callback: &callbackTracker{}, + text: "h", + canvas: image.Rect(0, 0, 4, 2), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 4, 2), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 3, 1), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "h", image.Point{1, 0}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom text color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + TextColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorRed), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom fill color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + FillColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorNumber(240))) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorRed)) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorRed)), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + { + desc: "sets custom shadow color", + callback: &callbackTracker{}, + text: "hello", + opts: []Option{ + ShadowColor(cell.ColorRed), + }, + canvas: image.Rect(0, 0, 8, 4), + want: func(size image.Point) *faketerm.Terminal { + ft := faketerm.MustNew(size) + cvs := testcanvas.MustNew(ft.Area()) + + // Shadow. + testcanvas.MustSetAreaCells(cvs, image.Rect(1, 1, 8, 4), 's', cell.BgColor(cell.ColorRed)) + + // Button. + testcanvas.MustSetAreaCells(cvs, image.Rect(0, 0, 7, 3), 'x', cell.BgColor(cell.ColorNumber(117))) + + // Text. + testdraw.MustText(cvs, "hello", image.Point{1, 1}, + draw.TextCellOpts( + cell.FgColor(cell.ColorBlack), + cell.BgColor(cell.ColorNumber(117))), + ) + + testcanvas.MustApply(cvs, ft) + return ft + }, + wantCallback: &callbackTracker{}, + }, + } + + buttonRune = 'x' + shadowRune = 's' + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + if tc.timeSince != nil { + timeSince = tc.timeSince + } else { + timeSince = time.Since + } + + gotCallback := tc.callback + var cFn CallbackFn + if gotCallback == nil { + cFn = nil + } else { + cFn = gotCallback.callback + } + b, err := New(tc.text, cFn, tc.opts...) + if (err != nil) != tc.wantNewErr { + t.Errorf("New => unexpected error: %v, wantNewErr: %v", err, tc.wantNewErr) + } + if err != nil { + return + } + + { + // Draw once which initializes the mouse state machine with the current canvas area. + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + err = b.Draw(c) + if (err != nil) != tc.wantDrawErr { + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) + } + if err != nil { + return + } + } + + for i, ev := range tc.events { + switch e := ev.(type) { + case *terminalapi.Mouse: + err := b.Mouse(e) + // Only the last event in test cases is the one that triggers the callback. + if i == len(tc.events)-1 { + if (err != nil) != tc.wantCallbackErr { + t.Errorf("Mouse => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr) + } + if err != nil { + return + } + } else { + if err != nil { + t.Fatalf("Mouse => unexpected error: %v", err) + } + } + + case *terminalapi.Keyboard: + err := b.Keyboard(e) + // Only the last event in test cases is the one that triggers the callback. + if i == len(tc.events)-1 { + if (err != nil) != tc.wantCallbackErr { + t.Errorf("Keyboard => unexpected error: %v, wantCallbackErr: %v", err, tc.wantCallbackErr) + } + if err != nil { + return + } + } else { + if err != nil { + t.Fatalf("Keyboard => unexpected error: %v", err) + } + } + + default: + t.Fatalf("unsupported event type: %T", ev) + } + } + + c, err := canvas.New(tc.canvas) + if err != nil { + t.Fatalf("canvas.New => unexpected error: %v", err) + } + + err = b.Draw(c) + if (err != nil) != tc.wantDrawErr { + t.Errorf("Draw => unexpected error: %v, wantDrawErr: %v", err, tc.wantDrawErr) + } + if err != nil { + return + } + + got, err := faketerm.New(c.Size()) + if err != nil { + t.Fatalf("faketerm.New => unexpected error: %v", err) + } + + if err := c.Apply(got); err != nil { + t.Fatalf("Apply => unexpected error: %v", err) + } + + var want *faketerm.Terminal + if tc.want != nil { + want = tc.want(c.Size()) + } else { + want = faketerm.MustNew(c.Size()) + } + + if diff := faketerm.Diff(want, got); diff != "" { + t.Errorf("Draw => %v", diff) + } + + if diff := pretty.Compare(tc.wantCallback, gotCallback); diff != "" { + t.Errorf("CallbackFn => unexpected diff (-want, +got):\n%s", diff) + } + }) + } +} + +func TestOptions(t *testing.T) { + tests := []struct { + desc string + text string + opts []Option + want widgetapi.Options + }{ + { + desc: "width is based on the text width by default", + text: "hello world", + want: widgetapi.Options{ + MinimumSize: image.Point{14, 4}, + MaximumSize: image.Point{14, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "width supports full-width unicode characters", + text: "■㈱の世界①", + want: widgetapi.Options{ + MinimumSize: image.Point{13, 4}, + MaximumSize: image.Point{13, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "width specified via WidthFor", + text: "hello", + opts: []Option{ + WidthFor("■㈱の世界①"), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{13, 4}, + MaximumSize: image.Point{13, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "custom height specified", + text: "hello", + opts: []Option{ + Height(10), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 11}, + MaximumSize: image.Point{8, 11}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "custom width specified", + text: "hello", + opts: []Option{ + Width(10), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{11, 4}, + MaximumSize: image.Point{11, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + + { + desc: "doesn't want keyboard by default", + text: "hello", + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeNone, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "registers for focused keyboard events", + text: "hello", + opts: []Option{ + Key(keyboard.KeyEnter), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeFocused, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + { + desc: "registers for global keyboard events", + text: "hello", + opts: []Option{ + GlobalKey(keyboard.KeyEnter), + }, + want: widgetapi.Options{ + MinimumSize: image.Point{8, 4}, + MaximumSize: image.Point{8, 4}, + WantKeyboard: widgetapi.KeyScopeGlobal, + WantMouse: widgetapi.MouseScopeGlobal, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + ct := &callbackTracker{} + b, err := New(tc.text, ct.callback, tc.opts...) + if err != nil { + t.Fatalf("New => unexpected error: %v", err) + } + + got := b.Options() + if diff := pretty.Compare(tc.want, got); diff != "" { + t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) + } + }) + } + +} diff --git a/widgets/button/buttondemo/buttondemo.go b/widgets/button/buttondemo/buttondemo.go new file mode 100644 index 0000000..2b4595c --- /dev/null +++ b/widgets/button/buttondemo/buttondemo.go @@ -0,0 +1,116 @@ +// 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 buttondemo shows the functionality of a button widget. +package main + +import ( + "context" + "fmt" + "time" + + "github.com/mum4k/termdash" + "github.com/mum4k/termdash/align" + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/container" + "github.com/mum4k/termdash/draw" + "github.com/mum4k/termdash/terminal/termbox" + "github.com/mum4k/termdash/terminalapi" + "github.com/mum4k/termdash/widgets/button" + "github.com/mum4k/termdash/widgets/segmentdisplay" +) + +func main() { + t, err := termbox.New() + if err != nil { + panic(err) + } + defer t.Close() + + ctx, cancel := context.WithCancel(context.Background()) + + val := 0 + display, err := segmentdisplay.New() + if err != nil { + panic(err) + } + if err := display.Write([]*segmentdisplay.TextChunk{ + segmentdisplay.NewChunk(fmt.Sprintf("%d", val)), + }); err != nil { + panic(err) + } + + addB, err := button.New("(a)dd", func() error { + val++ + return display.Write([]*segmentdisplay.TextChunk{ + segmentdisplay.NewChunk(fmt.Sprintf("%d", val)), + }) + }, + button.GlobalKey('a'), + button.WidthFor("(s)ubtract"), + ) + if err != nil { + panic(err) + } + + subB, err := button.New("(s)ubtract", func() error { + val-- + return display.Write([]*segmentdisplay.TextChunk{ + segmentdisplay.NewChunk(fmt.Sprintf("%d", val)), + }) + }, + button.FillColor(cell.ColorNumber(220)), + button.GlobalKey('s'), + ) + if err != nil { + panic(err) + } + + c, err := container.New( + t, + container.Border(draw.LineStyleLight), + container.BorderTitle("PRESS Q TO QUIT"), + container.SplitHorizontal( + container.Top( + container.PlaceWidget(display), + ), + container.Bottom( + container.SplitVertical( + container.Left( + container.PlaceWidget(addB), + container.AlignHorizontal(align.HorizontalRight), + ), + container.Right( + container.PlaceWidget(subB), + container.AlignHorizontal(align.HorizontalLeft), + ), + ), + ), + container.SplitPercent(60), + ), + ) + if err != nil { + panic(err) + } + + quitter := func(k *terminalapi.Keyboard) { + if k.Key == 'q' || k.Key == 'Q' { + cancel() + } + } + + if err := termdash.Run(ctx, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(100*time.Millisecond)); err != nil { + panic(err) + } +} diff --git a/widgets/button/options.go b/widgets/button/options.go new file mode 100644 index 0000000..3ac03d8 --- /dev/null +++ b/widgets/button/options.go @@ -0,0 +1,171 @@ +// Copyright 2019 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package button + +// options.go contains configurable options for Button. + +import ( + "fmt" + "time" + + "github.com/mum4k/termdash/cell" + "github.com/mum4k/termdash/cell/runewidth" + "github.com/mum4k/termdash/keyboard" + "github.com/mum4k/termdash/widgetapi" +) + +// 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 + shadowColor cell.Color + height int + width int + key keyboard.Key + keyScope widgetapi.KeyScope + keyUpDelay time.Duration +} + +// validate validates the provided options. +func (o *options) validate() error { + if min := 1; o.height < min { + return fmt.Errorf("invalid height %d, must be %d <= height", o.height, min) + } + if min := 1; o.width < min { + return fmt.Errorf("invalid width %d, must be %d <= width", o.width, min) + } + if min := time.Duration(0); o.keyUpDelay < min { + return fmt.Errorf("invalid keyUpDelay %v, must be %v <= keyUpDelay", o.keyUpDelay, min) + } + return nil +} + +// newOptions returns options with the default values set. +func newOptions(text string) *options { + return &options{ + fillColor: cell.ColorNumber(117), + textColor: cell.ColorBlack, + shadowColor: cell.ColorNumber(240), + height: DefaultHeight, + width: widthFor(text), + keyUpDelay: DefaultKeyUpDelay, + } +} + +// FillColor sets the fill color of the button. +func FillColor(c cell.Color) Option { + return option(func(opts *options) { + opts.fillColor = c + }) +} + +// TextColor sets the color of the text label in the button. +func TextColor(c cell.Color) Option { + return option(func(opts *options) { + opts.textColor = c + }) +} + +// ShadowColor sets the color of the shadow under the button. +func ShadowColor(c cell.Color) Option { + return option(func(opts *options) { + opts.shadowColor = c + }) +} + +// DefaultHeight is the default for the Height option. +const DefaultHeight = 3 + +// Height sets the height of the button in cells. +// Must be a positive non-zero integer. +// Defaults to DefaultHeight. +func Height(cells int) Option { + return option(func(opts *options) { + opts.height = cells + }) +} + +// Width sets the width of the button in cells. +// Must be a positive non-zero integer. +// Defaults to the auto-width based on the length of the text label. +func Width(cells int) Option { + return option(func(opts *options) { + opts.width = cells + }) +} + +// WidthFor sets the width of the button as if it was displaying the provided text. +// Useful when displaying multiple buttons with the intention to set all of +// their sizes equal to the one with the longest text. +func WidthFor(text string) Option { + return option(func(opts *options) { + opts.width = widthFor(text) + }) +} + +// Key configures the keyboard key that presses the button. +// The widget responds to this key only if its container if focused. +// When not provided, the widget ignores all keyboard events. +func Key(k keyboard.Key) Option { + return option(func(opts *options) { + opts.key = k + opts.keyScope = widgetapi.KeyScopeFocused + }) +} + +// GlobalKey is like Key, but makes the widget respond to the key even if its +// container isn't focused. +// When not provided, the widget ignores all keyboard events. +func GlobalKey(k keyboard.Key) Option { + return option(func(opts *options) { + opts.key = k + opts.keyScope = widgetapi.KeyScopeGlobal + }) +} + +// DefaultKeyUpDelay is the default value for the KeyUpDelay option. +const DefaultKeyUpDelay = 250 * time.Millisecond + +// KeyUpDelay is the amount of time the button will remain "pressed down" after +// triggered by the configured key. Termbox doesn't emit events for key +// releases so the button simulates it by timing it. +// This only works if the manual termdash redraw or the periodic redraw +// interval are reasonably close to this delay. +// The duration cannot be negative. +// Defaults to DefaultKeyUpDelay. +func KeyUpDelay(d time.Duration) Option { + return option(func(opts *options) { + opts.keyUpDelay = d + }) +} + +// widthFor returns the required width for the specified text. +func widthFor(text string) int { + return runewidth.StringWidth(text) + 2 // One empty cell at each side. +} diff --git a/widgets/donut/donut.go b/widgets/donut/donut.go index 8340221..47f0d6e 100644 --- a/widgets/donut/donut.go +++ b/widgets/donut/donut.go @@ -248,6 +248,6 @@ func (d *Donut) Options() widgetapi.Options { // The smallest circle that "looks" like a circle on the canvas. MinimumSize: image.Point{3, 3}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/donut/donut_test.go b/widgets/donut/donut_test.go index 0a5e27a..27239bd 100644 --- a/widgets/donut/donut_test.go +++ b/widgets/donut/donut_test.go @@ -666,7 +666,7 @@ func TestOptions(t *testing.T) { Ratio: image.Point{4, 2}, MinimumSize: image.Point{3, 3}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } if diff := pretty.Compare(want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) diff --git a/widgets/fakewidget/fakewidget.go b/widgets/fakewidget/fakewidget.go index 25617e5..fd675e5 100644 --- a/widgets/fakewidget/fakewidget.go +++ b/widgets/fakewidget/fakewidget.go @@ -173,7 +173,7 @@ func DrawWithMirror(mirror *Mirror, t terminalapi.Terminal, cvs *canvas.Canvas, for _, ev := range events { switch e := ev.(type) { case *terminalapi.Mouse: - if !mirror.opts.WantMouse { + if mirror.opts.WantMouse == widgetapi.MouseScopeNone { continue } if err := mirror.Mouse(e); err != nil { diff --git a/widgets/fakewidget/fakewidget_test.go b/widgets/fakewidget/fakewidget_test.go index cd11943..1dbe06c 100644 --- a/widgets/fakewidget/fakewidget_test.go +++ b/widgets/fakewidget/fakewidget_test.go @@ -324,7 +324,7 @@ func TestDraw(t *testing.T) { desc: "draws both keyboard and mouse events", opts: widgetapi.Options{ WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, cvs: testcanvas.MustNew(image.Rect(0, 0, 17, 5)), events: []terminalapi.Event{ diff --git a/widgets/gauge/gauge.go b/widgets/gauge/gauge.go index b1706fe..c657cf2 100644 --- a/widgets/gauge/gauge.go +++ b/widgets/gauge/gauge.go @@ -330,6 +330,6 @@ func (g *Gauge) Options() widgetapi.Options { MaximumSize: g.maxSize(), MinimumSize: g.minSize(), WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/gauge/gauge_test.go b/widgets/gauge/gauge_test.go index 35f6223..d32df9d 100644 --- a/widgets/gauge/gauge_test.go +++ b/widgets/gauge/gauge_test.go @@ -814,7 +814,7 @@ func TestOptions(t *testing.T) { MaximumSize: image.Point{0, 0}, // Unlimited. MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -826,7 +826,7 @@ func TestOptions(t *testing.T) { MaximumSize: image.Point{0, 2}, MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -839,7 +839,7 @@ func TestOptions(t *testing.T) { MaximumSize: image.Point{0, 4}, MinimumSize: image.Point{3, 3}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, } diff --git a/widgets/linechart/linechart.go b/widgets/linechart/linechart.go index 18d9d20..02f7931 100644 --- a/widgets/linechart/linechart.go +++ b/widgets/linechart/linechart.go @@ -492,7 +492,7 @@ func (lc *LineChart) Options() widgetapi.Options { return widgetapi.Options{ MinimumSize: lc.minSize(), - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, } } diff --git a/widgets/linechart/linechart_test.go b/widgets/linechart/linechart_test.go index bbaf4a1..c573642 100644 --- a/widgets/linechart/linechart_test.go +++ b/widgets/linechart/linechart_test.go @@ -1514,7 +1514,7 @@ func TestOptions(t *testing.T) { desc: "reserves space for axis without series", want: widgetapi.Options{ MinimumSize: image.Point{3, 4}, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, }, { @@ -1524,7 +1524,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{5, 4}, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, }, { @@ -1534,7 +1534,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{6, 4}, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, }, { @@ -1547,7 +1547,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{4, 5}, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, }, { @@ -1560,7 +1560,7 @@ func TestOptions(t *testing.T) { }, want: widgetapi.Options{ MinimumSize: image.Point{5, 7}, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, }, } diff --git a/widgets/segmentdisplay/segmentdisplay.go b/widgets/segmentdisplay/segmentdisplay.go index bc42f01..a1e02f6 100644 --- a/widgets/segmentdisplay/segmentdisplay.go +++ b/widgets/segmentdisplay/segmentdisplay.go @@ -259,6 +259,6 @@ func (sd *SegmentDisplay) Options() widgetapi.Options { // The smallest supported size of a display segment. MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/segmentdisplay/segmentdisplay_test.go b/widgets/segmentdisplay/segmentdisplay_test.go index b5f898b..4a5a484 100644 --- a/widgets/segmentdisplay/segmentdisplay_test.go +++ b/widgets/segmentdisplay/segmentdisplay_test.go @@ -822,7 +822,7 @@ func TestOptions(t *testing.T) { want := widgetapi.Options{ MinimumSize: image.Point{sixteen.MinCols, sixteen.MinRows}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } if diff := pretty.Compare(want, got); diff != "" { t.Errorf("Options => unexpected diff (-want, +got):\n%s", diff) diff --git a/widgets/sparkline/sparkline.go b/widgets/sparkline/sparkline.go index e0a1750..edf513c 100644 --- a/widgets/sparkline/sparkline.go +++ b/widgets/sparkline/sparkline.go @@ -231,6 +231,6 @@ func (sl *SparkLine) Options() widgetapi.Options { MinimumSize: min, MaximumSize: max, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, } } diff --git a/widgets/sparkline/sparkline_test.go b/widgets/sparkline/sparkline_test.go index 0a234ef..b85a3c7 100644 --- a/widgets/sparkline/sparkline_test.go +++ b/widgets/sparkline/sparkline_test.go @@ -470,7 +470,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -481,7 +481,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 2}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -493,7 +493,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{1, 3}, MaximumSize: image.Point{1, 3}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, { @@ -506,7 +506,7 @@ func TestOptions(t *testing.T) { MinimumSize: image.Point{1, 4}, MaximumSize: image.Point{1, 4}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, } diff --git a/widgets/text/text.go b/widgets/text/text.go index b9f0a15..61ca64b 100644 --- a/widgets/text/text.go +++ b/widgets/text/text.go @@ -303,16 +303,19 @@ func (t *Text) Mouse(m *terminalapi.Mouse) error { // Options of the widget func (t *Text) Options() widgetapi.Options { var ks widgetapi.KeyScope + var ms widgetapi.MouseScope if t.opts.disableScrolling { ks = widgetapi.KeyScopeNone + ms = widgetapi.MouseScopeNone } else { ks = widgetapi.KeyScopeFocused + ms = widgetapi.MouseScopeWidget } return widgetapi.Options{ // At least one line with at least one full-width rune. MinimumSize: image.Point{1, 1}, - WantMouse: !t.opts.disableScrolling, + WantMouse: ms, WantKeyboard: ks, } } diff --git a/widgets/text/text_test.go b/widgets/text/text_test.go index 45d10ba..0f3b35b 100644 --- a/widgets/text/text_test.go +++ b/widgets/text/text_test.go @@ -785,7 +785,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeFocused, - WantMouse: true, + WantMouse: widgetapi.MouseScopeWidget, }, }, { @@ -796,7 +796,7 @@ func TestOptions(t *testing.T) { want: widgetapi.Options{ MinimumSize: image.Point{1, 1}, WantKeyboard: widgetapi.KeyScopeNone, - WantMouse: false, + WantMouse: widgetapi.MouseScopeNone, }, }, }