ScrollLayout added and various improvements

This commit is contained in:
Timon Ringwald 2022-04-03 16:29:01 +02:00
parent c4a3aa05f9
commit a20d361871
18 changed files with 330 additions and 44 deletions

View File

@ -9,6 +9,7 @@ func drawBuffer(scr tcell.Screen, buf *ViewBuffer) {
// scr.SetContent(x, y, rn.Rn, nil, rn.Style) // scr.SetContent(x, y, rn.Rn, nil, rn.Style)
// }) // })
// TODO use runewidth.RuneWidth(rn)?
buf.ForEachLine(func(y int, content []Rune) { buf.ForEachLine(func(y int, content []Rune) {
for x := 0; x < buf.Width(); x++ { for x := 0; x < buf.Width(); x++ {
rn := content[x] rn := content[x]

View File

@ -2,7 +2,11 @@ package tui
type Events interface { type Events interface {
// KeyPressed is called every time a key or key-combination is pressed. // OnKeyPressed is called every time a key or key-combination is pressed.
// If KeyPressed returns true, the event will not be passed onto child views // If OnKeyPressed returns true, the event will not be passed onto child views
OnKeyPressed(event *KeyEvent) (consumed bool) OnKeyPressed(event *KeyEvent) (consumed bool)
// OnMouseClicked is called every time a mouse button was pressed on the view.
// If OnMouseClicked returns true, the event will not be passed onto child views
OnMouseClicked(event *MouseEvent) (consumed bool)
} }

2
go.mod
View File

@ -3,7 +3,7 @@ module git.tordarus.net/Tordarus/tui
go 1.18 go 1.18
require ( require (
git.tordarus.net/Tordarus/buf2d v1.1.1 git.tordarus.net/Tordarus/buf2d v1.1.2
github.com/gdamore/tcell v1.4.0 github.com/gdamore/tcell v1.4.0
golang.org/x/text v0.3.7 golang.org/x/text v0.3.7
) )

4
go.sum
View File

@ -1,5 +1,5 @@
git.tordarus.net/Tordarus/buf2d v1.1.1 h1:rYvQ2YveqogCoKy5andQxuORPusWbUhpnqJhzVkTlRs= git.tordarus.net/Tordarus/buf2d v1.1.2 h1:mmK3tARa30gCh4WaYoSEF5e7qk0C+1ODhxerUcfXN5M=
git.tordarus.net/Tordarus/buf2d v1.1.1/go.mod h1:XXPpS8nQK0gUI0ki7ftV/qlprsGCRWFVSD4ybvDuUL8= git.tordarus.net/Tordarus/buf2d v1.1.2/go.mod h1:XXPpS8nQK0gUI0ki7ftV/qlprsGCRWFVSD4ybvDuUL8=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU=

View File

@ -16,6 +16,9 @@ type Screen struct {
// KeyPressed is called every time a key or key-combination is pressed. // KeyPressed is called every time a key or key-combination is pressed.
KeyPressed func(event *KeyEvent) (consumed bool) KeyPressed func(event *KeyEvent) (consumed bool)
// MouseClicked is called every time a mouse button was pressed.
MouseClicked func(event *MouseEvent) (consumed bool)
} }
func NewScreen(root View) (*Screen, error) { func NewScreen(root View) (*Screen, error) {
@ -42,6 +45,8 @@ func (s *Screen) eventloop() {
go s.Redraw() go s.Redraw()
case *tcell.EventKey: case *tcell.EventKey:
go s.onKeyPressed(event) go s.onKeyPressed(event)
case *tcell.EventMouse:
go s.onMouseClicked(convertMouseEvent(event))
default: default:
s.StopWithError(errors.New(fmt.Sprintf("%#v", event))) s.StopWithError(errors.New(fmt.Sprintf("%#v", event)))
} }
@ -56,7 +61,7 @@ func (s *Screen) Start() error {
} }
defer s.scr.Fini() defer s.scr.Fini()
s.Redraw() s.scr.EnableMouse()
return <-s.stopCh return <-s.stopCh
} }
@ -75,6 +80,22 @@ func (s *Screen) onKeyPressed(event *KeyEvent) {
s.Redraw() s.Redraw()
} }
func (s *Screen) onMouseClicked(event *MouseEvent) {
if s.MouseClicked == nil || !s.MouseClicked(event) {
s.Root.OnMouseClicked(event)
}
s.Redraw()
}
func convertMouseEvent(original *tcell.EventMouse) *MouseEvent {
x, y := original.Position()
return &MouseEvent{
X: x, Y: y,
Button: convertMouseButton(original.Buttons()),
Modifiers: original.Modifiers(),
}
}
func (s *Screen) Redraw() { func (s *Screen) Redraw() {
w, h := s.scr.Size() w, h := s.scr.Size()

View File

@ -3,6 +3,10 @@ package tui
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"math/rand"
"os"
"strconv"
"testing" "testing"
"git.tordarus.net/Tordarus/tui" "git.tordarus.net/Tordarus/tui"
@ -10,6 +14,72 @@ import (
"github.com/gdamore/tcell" "github.com/gdamore/tcell"
) )
var logger *log.Logger
func initDebugLogger() func() {
out, err := os.Create("output.log")
if err != nil {
panic(err)
}
logger = log.New(out, "", log.LstdFlags|log.Lmicroseconds)
return func() {
out.Close()
}
}
func TestScrollView(t *testing.T) {
//defer initDebugLogger()()
textViews := make([]tui.View, 0, 50)
for i := 0; i < cap(textViews); i++ {
textViews = append(textViews, views.NewTextView(strconv.Itoa(i)))
textViews[i].SetStyle(textViews[i].Style().Foreground(tcell.ColorBlack).Background(tcell.Color(rand.Intn(int(tcell.ColorYellowGreen)))))
}
flowLayout := views.NewFlowLayout(tui.Vertical)
flowLayout.AppendViews(textViews...)
scrollView := views.NewScrollView(flowLayout)
screen, err := tui.NewScreen(scrollView)
if err != nil {
t.Error(err)
return
}
screen.KeyPressed = func(event *tui.KeyEvent) (consumed bool) {
switch event.Key() {
case tcell.KeyCtrlC:
screen.StopWithError(errors.New(fmt.Sprintf("key: %#v | rune: %s", event.Key(), string(event.Rune()))))
case tcell.KeyPgDn:
scrollView.Scroll(10, 0)
case tcell.KeyPgUp:
scrollView.Scroll(-10, 0)
}
return true
}
screen.MouseClicked = func(event *tui.MouseEvent) (consumed bool) {
//textViews[0].(*views.TextView).Text = fmt.Sprintf("mouse position: %d | %d", event.X, event.Y)
//textViews[1].(*views.TextView).Text = fmt.Sprintf("mouse button: %d", event.Button)
if event.Button == tui.MouseWheelUp {
scrollView.Scroll(-1, 0)
return true
} else if event.Button == tui.MouseWheelDown {
scrollView.Scroll(1, 0)
return true
}
return false
}
err = screen.Start()
fmt.Println(err)
}
func TestFlowLayout(t *testing.T) { func TestFlowLayout(t *testing.T) {
textView := views.NewTextView("hello world!") textView := views.NewTextView("hello world!")
textView.SetStyle(tui.StyleDefault.Background(tcell.ColorRed).Foreground(tcell.ColorBlack)) textView.SetStyle(tui.StyleDefault.Background(tcell.ColorRed).Foreground(tcell.ColorBlack))

View File

@ -54,3 +54,49 @@ const (
AnchorBottom AnchorBottom
AnchorBottomRight AnchorBottomRight
) )
type MouseEvent struct {
X, Y int
Button MouseButton
Modifiers tcell.ModMask
}
type MouseButton uint8
const (
MouseButtonNone MouseButton = iota
MouseButtonLeft
MouseButtonMiddle
MouseButtonRight
MouseButtonNext
MouseButtonPrev
MouseWheelUp
MouseWheelDown
MouseWheelLeft
MouseWheelRight
)
func convertMouseButton(mask tcell.ButtonMask) MouseButton {
if mask&tcell.Button1 == tcell.Button1 {
return MouseButtonLeft
} else if mask&tcell.Button2 == tcell.Button2 {
return MouseButtonMiddle
} else if mask&tcell.Button3 == tcell.Button3 {
return MouseButtonRight
} else if mask&tcell.Button4 == tcell.Button4 {
return MouseButtonNext
} else if mask&tcell.Button5 == tcell.Button5 {
return MouseButtonPrev
} else if mask&tcell.WheelUp == tcell.WheelUp {
return MouseWheelUp
} else if mask&tcell.WheelDown == tcell.WheelDown {
return MouseWheelDown
} else if mask&tcell.WheelLeft == tcell.WheelLeft {
return MouseWheelLeft
} else if mask&tcell.WheelRight == tcell.WheelRight {
return MouseWheelRight
}
return MouseButtonNone
}

View File

@ -3,7 +3,7 @@ package tui
import ( import (
"strings" "strings"
"golang.org/x/text/width" "github.com/mattn/go-runewidth"
) )
// WriteString writes a whole string to the buffer at position (x,y) // WriteString writes a whole string to the buffer at position (x,y)
@ -56,15 +56,16 @@ func MeasureMultiLineString(str string) (maxLineWidth, lineCount int) {
} }
func runeWidth(r rune) int { func runeWidth(r rune) int {
return runewidth.RuneWidth(r)
//fmt.Println(r, width.LookupRune(r).Kind()) //fmt.Println(r, width.LookupRune(r).Kind())
switch width.LookupRune(r).Kind() { // switch width.LookupRune(r).Kind() {
case width.EastAsianFullwidth: // case width.EastAsianFullwidth:
fallthrough // fallthrough
case width.EastAsianWide: // case width.EastAsianWide:
return 2 // return 2
default: // default:
return 1 // return 1
} // }
} }
func min(x, y int) int { func min(x, y int) int {

View File

@ -58,6 +58,9 @@ func (g *FlowLayout) Draw(buf *tui.ViewBuffer) {
if g.Orientation == tui.Horizontal { if g.Orientation == tui.Horizontal {
remainingSpacePerView := buf.Width() - layout.Sum.Width remainingSpacePerView := buf.Width() - layout.Sum.Width
if remainingSpacePerView < 0 {
remainingSpacePerView = 0
}
if layout.HorizontalNegativeCount > 0 { if layout.HorizontalNegativeCount > 0 {
remainingSpacePerView /= layout.HorizontalNegativeCount remainingSpacePerView /= layout.HorizontalNegativeCount
} }
@ -72,10 +75,17 @@ func (g *FlowLayout) Draw(buf *tui.ViewBuffer) {
} }
view.Draw(buf.Sub(x, 0, size.Width, size.Height)) view.Draw(buf.Sub(x, 0, size.Width, size.Height))
x += size.Width x += size.Width
if x >= buf.Width() {
break
}
} }
} else if g.Orientation == tui.Vertical { } else if g.Orientation == tui.Vertical {
remainingSpacePerView := buf.Height() - layout.Sum.Height remainingSpacePerView := buf.Height() - layout.Sum.Height
if remainingSpacePerView < 0 {
remainingSpacePerView = 0
}
if layout.VerticalNegativeCount > 0 { if layout.VerticalNegativeCount > 0 {
remainingSpacePerView /= layout.VerticalNegativeCount remainingSpacePerView /= layout.VerticalNegativeCount
} }
@ -90,7 +100,11 @@ func (g *FlowLayout) Draw(buf *tui.ViewBuffer) {
} }
view.Draw(buf.Sub(0, y, size.Width, size.Height)) view.Draw(buf.Sub(0, y, size.Width, size.Height))
y += size.Height y += size.Height
if y >= buf.Height() {
break
}
} }
} }
@ -106,7 +120,7 @@ func (g *FlowLayout) Layout() (prefWidth, prefHeight int) {
prefHeight = iff(layout.VerticalNegativeCount == 0, layout.Max.Height, -1) prefHeight = iff(layout.VerticalNegativeCount == 0, layout.Max.Height, -1)
} else if g.Orientation == tui.Vertical { } else if g.Orientation == tui.Vertical {
prefWidth = iff(layout.HorizontalNegativeCount == 0, layout.Max.Width, -1) prefWidth = iff(layout.HorizontalNegativeCount == 0, layout.Max.Width, -1)
prefHeight = iff(layout.VerticalNegativeCount == 0, layout.Sum.Width, -1) prefHeight = iff(layout.VerticalNegativeCount == 0, layout.Sum.Height, -1)
} }
layout.Pref = tui.Size{Width: prefWidth, Height: prefHeight} layout.Pref = tui.Size{Width: prefWidth, Height: prefHeight}

View File

@ -20,6 +20,10 @@ func max(x, y int) int {
return y return y
} }
func limit(v, minv, maxv int) int {
return min(max(v, minv), maxv)
}
func iff[T any](condition bool, trueValue, falseValue T) T { func iff[T any](condition bool, trueValue, falseValue T) T {
if condition { if condition {
return trueValue return trueValue

View File

@ -10,7 +10,7 @@ type BorderView struct {
Border BorderBox Border BorderBox
} }
var _ tui.View = &BorderView{} var _ tui.Wrapper = &BorderView{}
func NewBorderView(view tui.View) *BorderView { func NewBorderView(view tui.View) *BorderView {
v := new(BorderView) v := new(BorderView)
@ -46,10 +46,6 @@ func (v *BorderView) Layout() (prefWidth, prefHeight int) {
return w, h return w, h
} }
func (v *BorderView) Style() tui.Style {
return v.ViewTmpl.Style()
}
type BorderBox struct { type BorderBox struct {
TopLeft rune TopLeft rune
TopRight rune TopRight rune

View File

@ -11,7 +11,7 @@ type ConstrainView struct {
MaxHeight int MaxHeight int
} }
var _ tui.View = &ConstrainView{} var _ tui.Wrapper = &ConstrainView{}
func NewConstrainView(view tui.View) *ConstrainView { func NewConstrainView(view tui.View) *ConstrainView {
v := new(ConstrainView) v := new(ConstrainView)

View File

@ -8,7 +8,7 @@ type FrameView struct {
Anchor tui.Anchor Anchor tui.Anchor
} }
var _ tui.View = &FrameView{} var _ tui.Wrapper = &FrameView{}
func NewFrameView(view tui.View) *FrameView { func NewFrameView(view tui.View) *FrameView {
v := new(FrameView) v := new(FrameView)
@ -29,7 +29,3 @@ func (g *FrameView) Draw(buf *tui.ViewBuffer) {
func (v *FrameView) Layout() (prefWidth, prefHeight int) { func (v *FrameView) Layout() (prefWidth, prefHeight int) {
return -1, -1 return -1, -1
} }
func (v *FrameView) Style() tui.Style {
return v.ViewTmpl.Style()
}

View File

@ -8,7 +8,7 @@ type MarginView struct {
Margin map[tui.Side]int Margin map[tui.Side]int
} }
var _ tui.View = &MarginView{} var _ tui.Wrapper = &MarginView{}
func NewMarginView(view tui.View) *MarginView { func NewMarginView(view tui.View) *MarginView {
v := new(MarginView) v := new(MarginView)
@ -34,10 +34,6 @@ func (v *MarginView) Layout() (prefWidth, prefHeight int) {
return w, h return w, h
} }
func (v *MarginView) Style() tui.Style {
return v.ViewTmpl.Style()
}
func (v *MarginView) SetMargin(top, right, bottom, left int) { func (v *MarginView) SetMargin(top, right, bottom, left int) {
v.Margin = map[tui.Side]int{ v.Margin = map[tui.Side]int{
tui.SideTop: top, tui.SideTop: top,

140
views/view_scroll.go Normal file
View File

@ -0,0 +1,140 @@
package views
import (
"math"
"git.tordarus.net/Tordarus/buf2d"
"git.tordarus.net/Tordarus/tui"
"github.com/gdamore/tcell"
)
// ScrollView is a tui.View which can hold an arbitrary large view.
// If the sub view does not fit into its bounds, scroll bars will be shown
type ScrollView struct {
tui.WrapperTmpl
buf *tui.ViewBuffer
width, height int
verticalScrollOffset int
horizontalScrollOffset int
}
var _ tui.Wrapper = &ScrollView{}
func NewScrollView(view tui.View) *ScrollView {
v := new(ScrollView)
v.SetView(view)
return v
}
func (v *ScrollView) Draw(buf *tui.ViewBuffer) {
w, h := v.View().Layout()
if v.buf == nil || v.buf.Width() != w || v.buf.Height() != h {
v.buf = buf2d.NewBuffer(w, h, tui.DefaultRune)
}
v.Scroll(0, 0) // limit scroll offset boundaries
v.View().Draw(v.buf)
scrollH, scrollV := v.determineViewportSize(buf)
copyBufferWidth, copyBufferHeight := 0, 0
if scrollH {
copyBufferWidth = v.width
} else {
copyBufferWidth = v.buf.Width()
}
if scrollV {
copyBufferHeight = v.height
} else {
copyBufferHeight = v.buf.Height()
}
scrollVHeight := int(float64(buf.Height()) / float64(v.buf.Height()) * float64(v.height))
scrollVStart := int(math.Ceil(float64(v.verticalScrollOffset) / float64(v.buf.Height()) * float64(v.height)))
scrollHStart := int(float64(v.horizontalScrollOffset) / float64(v.buf.Width()) * float64(v.width))
scrollHWidth := int(math.Ceil(float64(buf.Width()) / float64(v.buf.Width()) * float64(v.width)))
// guarantee minimum scroll bar thumb size of 1 TODO inaccurate for small scales
if scrollVHeight <= 0 {
scrollVHeight = 1
if scrollVStart >= v.height {
scrollVStart = v.height - 1
}
}
if scrollHWidth <= 0 {
scrollHWidth = 1
if scrollHStart >= v.width {
scrollHStart = v.width - 1
}
}
// copy buffer
for x := 0; x < copyBufferWidth; x++ {
for y := 0; y < copyBufferHeight; y++ {
buf.Set(x, y, v.buf.Get(v.horizontalScrollOffset+x, v.verticalScrollOffset+y))
}
}
// vertical scrollbar
if scrollV {
for y := 0; y < v.height; y++ {
var style tcell.Style
if y >= scrollVStart && y < scrollVStart+scrollVHeight {
style = tui.StyleDefault.Background(tcell.ColorWhite)
} else {
style = tui.StyleDefault.Background(tcell.ColorDarkSlateGray)
}
buf.Set(v.width, y, tui.Rune{Rn: ' ', Style: style})
}
}
// horizontal scrollbar
if scrollH {
for x := 0; x < v.width; x++ {
var style tcell.Style
if x >= scrollHStart && x < scrollHStart+scrollHWidth {
style = tui.StyleDefault.Background(tcell.ColorWhite)
} else {
style = tui.StyleDefault.Background(tcell.ColorDarkSlateGray)
}
buf.Set(x, v.height, tui.Rune{Rn: ' ', Style: style})
}
}
}
func (v *ScrollView) Layout() (prefWidth, prefHeight int) {
return -1, -1
}
func (v *ScrollView) Scroll(verticalOffset, horizontalOffset int) {
if v.buf != nil {
v.verticalScrollOffset = limit(v.verticalScrollOffset+verticalOffset, 0, max(v.buf.Height()-v.height, 0))
v.horizontalScrollOffset = limit(v.horizontalScrollOffset+horizontalOffset, 0, max(v.buf.Width()-v.width, 0))
} else {
v.verticalScrollOffset = v.verticalScrollOffset + verticalOffset
v.horizontalScrollOffset = v.horizontalScrollOffset + horizontalOffset
}
}
func (v *ScrollView) determineViewportSize(buf *tui.ViewBuffer) (scrollbarH, scrollbarV bool) {
v.width, v.height = buf.Width()-1, buf.Height()-1
scrollbarV = v.buf.Height() > v.height
scrollbarH = v.buf.Width() > v.width
if scrollbarV && !scrollbarH {
v.height++
scrollbarV = v.buf.Height() > v.height
}
if !scrollbarV && scrollbarH {
v.width++
scrollbarH = v.buf.Width() > v.width
}
return
}

View File

@ -12,17 +12,17 @@ type TextView struct {
var _ tui.View = &TextView{} var _ tui.View = &TextView{}
func (v *TextView) Draw(buf *tui.ViewBuffer) {
v.ViewTmpl.Draw(buf)
tui.WriteMultiLineString(buf, v.Text, v.Style(), 0, 0)
}
func NewTextView(text string) *TextView { func NewTextView(text string) *TextView {
return &TextView{ return &TextView{
Text: text, Text: text,
} }
} }
func (v *TextView) Draw(buf *tui.ViewBuffer) {
v.ViewTmpl.Draw(buf)
tui.WriteMultiLineString(buf, v.Text, v.Style(), 0, 0)
}
func (v *TextView) Layout() (prefWidth, prefHeight int) { func (v *TextView) Layout() (prefWidth, prefHeight int) {
return tui.MeasureMultiLineString(v.Text) return tui.MeasureMultiLineString(v.Text)
} }

View File

@ -18,6 +18,10 @@ func (v *ViewTmpl) OnKeyPressed(event *KeyEvent) (consumed bool) {
return false return false
} }
func (v *ViewTmpl) OnMouseClicked(event *MouseEvent) (consumed bool) {
return false
}
func (v *ViewTmpl) SetStyle(s Style) { func (v *ViewTmpl) SetStyle(s Style) {
v.style = &s v.style = &s
} }

View File

@ -30,17 +30,10 @@ func (v *WrapperTmpl) OnKeyPressed(event *KeyEvent) (consumed bool) {
} }
func (v *WrapperTmpl) SetStyle(s Style) { func (v *WrapperTmpl) SetStyle(s Style) {
if v.view != nil {
v.view.SetStyle(s)
return
}
v.ViewTmpl.SetStyle(s) v.ViewTmpl.SetStyle(s)
} }
func (v *WrapperTmpl) Style() Style { func (v *WrapperTmpl) Style() Style {
if v.view != nil {
return v.view.Style()
}
return v.ViewTmpl.Style() return v.ViewTmpl.Style()
} }