diff --git a/draw_buffer.go b/draw_buffer.go index 82b886a..999ea43 100644 --- a/draw_buffer.go +++ b/draw_buffer.go @@ -9,6 +9,7 @@ func drawBuffer(scr tcell.Screen, buf *ViewBuffer) { // scr.SetContent(x, y, rn.Rn, nil, rn.Style) // }) + // TODO use runewidth.RuneWidth(rn)? buf.ForEachLine(func(y int, content []Rune) { for x := 0; x < buf.Width(); x++ { rn := content[x] diff --git a/events.go b/events.go index 8f40df2..74767ea 100644 --- a/events.go +++ b/events.go @@ -2,7 +2,11 @@ package tui type Events interface { - // KeyPressed is called every time a key or key-combination is pressed. - // If KeyPressed returns true, the event will not be passed onto child views + // OnKeyPressed is called every time a key or key-combination is pressed. + // If OnKeyPressed returns true, the event will not be passed onto child views 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) } diff --git a/go.mod b/go.mod index 363173b..d1ae516 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.tordarus.net/Tordarus/tui go 1.18 require ( - git.tordarus.net/Tordarus/buf2d v1.1.1 + git.tordarus.net/Tordarus/buf2d v1.1.2 github.com/gdamore/tcell v1.4.0 golang.org/x/text v0.3.7 ) diff --git a/go.sum b/go.sum index 0369fb0..a1cdd8d 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.tordarus.net/Tordarus/buf2d v1.1.1 h1:rYvQ2YveqogCoKy5andQxuORPusWbUhpnqJhzVkTlRs= -git.tordarus.net/Tordarus/buf2d v1.1.1/go.mod h1:XXPpS8nQK0gUI0ki7ftV/qlprsGCRWFVSD4ybvDuUL8= +git.tordarus.net/Tordarus/buf2d v1.1.2 h1:mmK3tARa30gCh4WaYoSEF5e7qk0C+1ODhxerUcfXN5M= +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/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= diff --git a/screen.go b/screen.go index ba9e39c..4fed4f7 100644 --- a/screen.go +++ b/screen.go @@ -16,6 +16,9 @@ type Screen struct { // KeyPressed is called every time a key or key-combination is pressed. 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) { @@ -42,6 +45,8 @@ func (s *Screen) eventloop() { go s.Redraw() case *tcell.EventKey: go s.onKeyPressed(event) + case *tcell.EventMouse: + go s.onMouseClicked(convertMouseEvent(event)) default: s.StopWithError(errors.New(fmt.Sprintf("%#v", event))) } @@ -56,7 +61,7 @@ func (s *Screen) Start() error { } defer s.scr.Fini() - s.Redraw() + s.scr.EnableMouse() return <-s.stopCh } @@ -75,6 +80,22 @@ func (s *Screen) onKeyPressed(event *KeyEvent) { 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() { w, h := s.scr.Size() diff --git a/tests/screen_test.go b/tests/screen_test.go index 9f0adc7..45820da 100644 --- a/tests/screen_test.go +++ b/tests/screen_test.go @@ -3,6 +3,10 @@ package tui import ( "errors" "fmt" + "log" + "math/rand" + "os" + "strconv" "testing" "git.tordarus.net/Tordarus/tui" @@ -10,6 +14,72 @@ import ( "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) { textView := views.NewTextView("hello world!") textView.SetStyle(tui.StyleDefault.Background(tcell.ColorRed).Foreground(tcell.ColorBlack)) diff --git a/types.go b/types.go index 429b0e3..5aa75ba 100644 --- a/types.go +++ b/types.go @@ -54,3 +54,49 @@ const ( AnchorBottom 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 +} diff --git a/utils.go b/utils.go index 8a28863..e2ec568 100644 --- a/utils.go +++ b/utils.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "golang.org/x/text/width" + "github.com/mattn/go-runewidth" ) // 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 { + return runewidth.RuneWidth(r) //fmt.Println(r, width.LookupRune(r).Kind()) - switch width.LookupRune(r).Kind() { - case width.EastAsianFullwidth: - fallthrough - case width.EastAsianWide: - return 2 - default: - return 1 - } + // switch width.LookupRune(r).Kind() { + // case width.EastAsianFullwidth: + // fallthrough + // case width.EastAsianWide: + // return 2 + // default: + // return 1 + // } } func min(x, y int) int { diff --git a/views/layout_flow.go b/views/layout_flow.go index a3c9557..9f71b5b 100644 --- a/views/layout_flow.go +++ b/views/layout_flow.go @@ -58,6 +58,9 @@ func (g *FlowLayout) Draw(buf *tui.ViewBuffer) { if g.Orientation == tui.Horizontal { remainingSpacePerView := buf.Width() - layout.Sum.Width + if remainingSpacePerView < 0 { + remainingSpacePerView = 0 + } if layout.HorizontalNegativeCount > 0 { remainingSpacePerView /= layout.HorizontalNegativeCount } @@ -72,10 +75,17 @@ func (g *FlowLayout) Draw(buf *tui.ViewBuffer) { } view.Draw(buf.Sub(x, 0, size.Width, size.Height)) + x += size.Width + if x >= buf.Width() { + break + } } } else if g.Orientation == tui.Vertical { remainingSpacePerView := buf.Height() - layout.Sum.Height + if remainingSpacePerView < 0 { + remainingSpacePerView = 0 + } if layout.VerticalNegativeCount > 0 { remainingSpacePerView /= layout.VerticalNegativeCount } @@ -90,7 +100,11 @@ func (g *FlowLayout) Draw(buf *tui.ViewBuffer) { } view.Draw(buf.Sub(0, y, size.Width, 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) } else if g.Orientation == tui.Vertical { 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} diff --git a/views/utils.go b/views/utils.go index 52ea38e..a18a257 100644 --- a/views/utils.go +++ b/views/utils.go @@ -20,6 +20,10 @@ func max(x, y int) int { 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 { if condition { return trueValue diff --git a/views/view_border.go b/views/view_border.go index 8174f66..8f8611a 100644 --- a/views/view_border.go +++ b/views/view_border.go @@ -10,7 +10,7 @@ type BorderView struct { Border BorderBox } -var _ tui.View = &BorderView{} +var _ tui.Wrapper = &BorderView{} func NewBorderView(view tui.View) *BorderView { v := new(BorderView) @@ -46,10 +46,6 @@ func (v *BorderView) Layout() (prefWidth, prefHeight int) { return w, h } -func (v *BorderView) Style() tui.Style { - return v.ViewTmpl.Style() -} - type BorderBox struct { TopLeft rune TopRight rune diff --git a/views/view_constrain.go b/views/view_constrain.go index 5f0010d..3bb8e6d 100644 --- a/views/view_constrain.go +++ b/views/view_constrain.go @@ -11,7 +11,7 @@ type ConstrainView struct { MaxHeight int } -var _ tui.View = &ConstrainView{} +var _ tui.Wrapper = &ConstrainView{} func NewConstrainView(view tui.View) *ConstrainView { v := new(ConstrainView) diff --git a/views/view_frame.go b/views/view_frame.go index f4e3437..e2b4661 100644 --- a/views/view_frame.go +++ b/views/view_frame.go @@ -8,7 +8,7 @@ type FrameView struct { Anchor tui.Anchor } -var _ tui.View = &FrameView{} +var _ tui.Wrapper = &FrameView{} func NewFrameView(view tui.View) *FrameView { v := new(FrameView) @@ -29,7 +29,3 @@ func (g *FrameView) Draw(buf *tui.ViewBuffer) { func (v *FrameView) Layout() (prefWidth, prefHeight int) { return -1, -1 } - -func (v *FrameView) Style() tui.Style { - return v.ViewTmpl.Style() -} diff --git a/views/view_margin.go b/views/view_margin.go index b231dc7..a312bd9 100644 --- a/views/view_margin.go +++ b/views/view_margin.go @@ -8,7 +8,7 @@ type MarginView struct { Margin map[tui.Side]int } -var _ tui.View = &MarginView{} +var _ tui.Wrapper = &MarginView{} func NewMarginView(view tui.View) *MarginView { v := new(MarginView) @@ -34,10 +34,6 @@ func (v *MarginView) Layout() (prefWidth, prefHeight int) { return w, h } -func (v *MarginView) Style() tui.Style { - return v.ViewTmpl.Style() -} - func (v *MarginView) SetMargin(top, right, bottom, left int) { v.Margin = map[tui.Side]int{ tui.SideTop: top, diff --git a/views/view_scroll.go b/views/view_scroll.go new file mode 100644 index 0000000..8cd0dcb --- /dev/null +++ b/views/view_scroll.go @@ -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 +} diff --git a/views/view_text.go b/views/view_text.go index 6be4400..9bf71a8 100644 --- a/views/view_text.go +++ b/views/view_text.go @@ -12,17 +12,17 @@ type TextView struct { 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 { return &TextView{ 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) { return tui.MeasureMultiLineString(v.Text) } diff --git a/viewtmpl.go b/viewtmpl.go index a050c34..0ec5181 100644 --- a/viewtmpl.go +++ b/viewtmpl.go @@ -18,6 +18,10 @@ func (v *ViewTmpl) OnKeyPressed(event *KeyEvent) (consumed bool) { return false } +func (v *ViewTmpl) OnMouseClicked(event *MouseEvent) (consumed bool) { + return false +} + func (v *ViewTmpl) SetStyle(s Style) { v.style = &s } diff --git a/wrappertmpl.go b/wrappertmpl.go index 64c2c36..63e54ff 100644 --- a/wrappertmpl.go +++ b/wrappertmpl.go @@ -30,17 +30,10 @@ func (v *WrapperTmpl) OnKeyPressed(event *KeyEvent) (consumed bool) { } func (v *WrapperTmpl) SetStyle(s Style) { - if v.view != nil { - v.view.SetStyle(s) - return - } v.ViewTmpl.SetStyle(s) } func (v *WrapperTmpl) Style() Style { - if v.view != nil { - return v.view.Style() - } return v.ViewTmpl.Style() }