64 Commits

Author SHA1 Message Date
Christian Rocha
a498b857d6 Update paginator for bubbletea 0.12.0 2020-10-16 14:36:08 -04:00
Christian Rocha
82097a1c79 Remove unused function 2020-10-16 11:54:44 -04:00
Christian Rocha
a8ec421800 Update bubbletea and termenv dependencies 2020-10-16 11:54:00 -04:00
Christian Rocha
9c10cd0586 Remove unused ErrMsg type from textinput module 2020-10-15 18:52:36 -04:00
Christian Rocha
154f3763f7 textinput.View can just accept a textinput.Model argument 2020-10-15 18:43:08 -04:00
Christian Rocha
eff931bdea Remove replace directive for bubbletea in go.mod 2020-10-12 22:15:02 -04:00
Christian Rocha
0d7b3ed8e8 Fix URLs to Charm and Glow in README 2020-10-06 13:25:46 -04:00
Christian Rocha
cbafae84f9 Update mouse wheel stuff in paginator per last-minute Bubble Tea API change 2020-10-06 12:29:18 -04:00
Christian Rocha
959598c9aa Fix outdated comment 2020-10-02 13:03:04 -04:00
Christian Rocha
5f7c8b5375 Simplify the API around fine-grained spinner display rules 2020-08-25 09:36:43 -04:00
Christian Rocha
75df6b96a1 Don't return the current frame in TickMsg 2020-08-24 21:09:28 -04:00
Christian Rocha
084d0acefd Fix comments + minimum lifetime should work in conjunction with HideFor 2020-08-24 21:07:45 -04:00
Christian Rocha
e8526ec4ef Optional fine-grained rules for the spinner about when and how long to show it 2020-08-24 20:03:54 -04:00
Christian Rocha
68f11d0b63 Update termenv to 0.7.2 2020-08-22 11:24:06 -04:00
Christian Muehlhaeuser
f78a02cc29 Avoid unnecessary conversions 2020-08-22 12:41:37 +02:00
Christian Muehlhaeuser
9a4fbb3be1 Make golint happy 2020-08-22 12:41:09 +02:00
Christian Rocha
d4a0ecd3ee Bump bubbletea dependency and set termenv dependency to v0.7.0 2020-08-20 13:31:31 -04:00
Christian Rocha
34b1f47068 Fix link to Bubble Tea in README 2020-07-31 19:11:13 -04:00
Christian Rocha
dbd40713ce Remove extraneous methods 2020-07-29 20:04:09 -04:00
Christian Rocha
54a0d84255 Add badges 2020-07-29 19:24:09 -04:00
Christian Rocha
004511e00f Add example GIFs 2020-07-29 19:20:41 -04:00
Christian Rocha
7fa53ea961 Link directly to example code and flesh out pagination component description 2020-07-29 16:38:59 -04:00
Christian Rocha
0cc5e71a63 Flesh out README and add Charm badge 2020-07-29 16:34:01 -04:00
Christian Rocha
f11ca377f4 Add clipboard paste support to textarea 2020-07-23 11:53:58 -04:00
Christian Rocha
10022c964c Fix slice out of bounds errors that could happen when deleting text 2020-07-21 18:22:18 -04:00
Christian Rocha
5357dd61bd Fix bug where scroll wheel could create duplicate lines on top and bottom 2020-07-20 17:46:15 -04:00
Christian Rocha
5a26cb0d8e Use pgup/pgdown as default paginator keystrokes 2020-07-20 12:13:53 -04:00
Christian Rocha
88469a499e Tidy up the key case switch in viewport 2020-07-20 11:56:35 -04:00
Christian Rocha
b130d96434 Add methods for jumping to the tom and bottom of the viewport 2020-07-17 19:22:08 -04:00
Christian Rocha
185a19e56f Fix cases where pager would add blank lines to bottom of document 2020-07-17 18:38:38 -04:00
Christian Rocha
dbb1d93970 Remove redundant viewport commands 2020-07-17 18:22:51 -04:00
Christian Rocha
2f909886c1 Be more consistent with max() argument order 2020-07-17 18:13:48 -04:00
Christian Rocha
5720cfb35a Correct scroll percentage reporting 2020-07-17 18:13:05 -04:00
Christian Rocha
3c07b97d37 Correct typo, add potential TODO 2020-07-17 17:02:37 -04:00
Christian Rocha
9b3e5476c3 Update README a little. 2020-07-17 14:03:02 -04:00
Christian Rocha
74e84eca55 Bump Bubbletea dependency 2020-07-14 18:26:59 -04:00
Christian Rocha
ccf4c90b6b Rework spinner to allow for user-defined custom spinners 2020-07-14 18:21:48 -04:00
Christian Rocha
f341e3c896 Correct mouse wheel behavior 2020-06-23 13:10:50 -04:00
Christian Rocha
c9196e5407 Add basic mouse wheel support to viewport 2020-06-23 12:00:17 -04:00
Christian Rocha
f967f6a87f Fix a bug where placeholder text would not reappear post-input 2020-06-22 15:28:38 -04:00
Christian Rocha
35c3cd626d Fix spinner frame skipping and remove custom message functionality 2020-06-22 14:49:21 -04:00
Christian Rocha
2525319d72 Bump dependencies 2020-06-22 13:51:51 -04:00
Christian Rocha
e9dd6b06e0 Fix duplicate character bug when deleting chars in long strings
If the string is longer than the width of the field and the horizontal
viewport (so to speak) was on the right edge, the last character would
repeat when deleting characters because the viewport offset wasn't being
corrected. This fixes that.
2020-06-20 23:34:19 -04:00
Christian Rocha
7cef3a6f59 Document comments 2020-06-20 13:59:13 -04:00
Christian Rocha
1cdc2045c7 Fix a potential out of bounds panic 2020-06-19 19:46:51 -04:00
Christian Rocha
cc480dd2f3 Comments 2020-06-19 19:02:14 -04:00
Christian Rocha
0c1781fbb3 Clamp scroll percentage 2020-06-19 15:04:23 -04:00
Christian Rocha
5255143e87 Rework high performance half-view up/down 2020-06-19 13:23:14 -04:00
Christian Rocha
1629afe087 Rework high-performance pgup/pgdown 2020-06-19 12:20:35 -04:00
Christian Rocha
da3150ded7 Rework high performance line-up/line-down 2020-06-19 11:51:44 -04:00
Christian Rocha
b82cf5071d Send newlines instead of cursor-downs in performance viewport 2020-06-18 18:31:30 -04:00
Christian Rocha
d095a6554c Retire the internal viewport renderer 2020-06-18 18:25:57 -04:00
Christian Rocha
34ac608122 Correct slice bounding 2020-06-18 16:29:05 -04:00
Christian Rocha
09ae5da7c3 Don't get/set size in viewport; that should happen in the parent 2020-06-18 14:14:21 -04:00
Christian Rocha
5572542e2e Add command to sync initial high performance view 2020-06-18 13:42:11 -04:00
Christian Rocha
3688351ddf Viewport now can use Bubble Tea high performance scroll renderer (ish) 2020-06-17 20:55:49 -04:00
Christian Rocha
d9c03fc0b0 Add cursor positioning functions to viewport renderder 2020-06-16 16:10:34 -04:00
Christian Rocha
3321ac12a9 Renderer comments 2020-06-16 14:34:46 -04:00
Christian Rocha
0eaea5cc5d Use as much terminal movement from termenv as we can 2020-06-16 14:32:00 -04:00
Christian Rocha
0243dff9d3 Remove irrelevant (and erroneous) width settings in viewport renderer 2020-06-16 14:28:01 -04:00
Christian Rocha
68ec6c7ffc Integrate viewport navigation controls with new renderer 2020-06-15 21:22:25 -04:00
Christian Rocha
f332bf2cc2 Simplify the render's write() method and it now takes a slice of lines 2020-06-15 21:21:08 -04:00
Christian Rocha
0b19d41e0a Broad (working) pass at viewport renderer 2020-06-15 21:21:08 -04:00
Christian Rocha
786ec557d4 Bumb Bubble Tea dependency 2020-06-15 12:02:14 -04:00
7 changed files with 578 additions and 192 deletions

View File

@@ -1,9 +1,79 @@
# Tea Party Bubbles
=======
Components for [Tea](https://github.com/charmbraclet/tea) <p>
<a href="https://pkg.go.dev/github.com/charmbracelet/bubbles?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
<a href="https://github.com/charmbracelet/bubbles/actions"><img src="https://github.com/charmbracelet/glow/workflows/build/badge.svg" alt="Build Status"></a>
</p>
Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications.
These components are used in production in [Glow][glow] and [Charm][charm].
[glow]: https://github.com/charmbracelet/glow
[charm]: https://github.com/charmbracelet/charm
## Spinner
<img src="https://stuff.charm.sh/bubbles-examples/spinner.gif" width="400" alt="Spinner Example">
A spinner, useful for indicating that some kind an operation is happening.
There are a couple default ones, but you can also pass your own ”frames.”
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/spinner/main.go)
## Text Input
<img src="https://stuff.charm.sh/bubbles-examples/textinput.gif" width="400" alt="Text Input Example">
A text input field, akin to an `<input type="text">` in HTML. Supports unicode,
pasting, in-place scrolling when the value exceeds the width of the element and
the common, and many customization options.
* [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go)
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go)
## Paginator
<img src="https://stuff.charm.sh/bubbles-examples/pagination.gif" width="200" alt="Paginator Example">
A component for handling pagination logic and optionally drawing pagination UI.
Supports "dot-style" pagination (similar to what you might see on iOS) and
numeric page numbering, but you could also just use this component for the
logic and visualize pagination however you like.
This component is used in [Glow][glow] to browse documents and [Charm][charm] to
browse SSH keys.
## Viewport
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif" width="600" alt="Viewport Example">
A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available
for applications which make use of the alterate screen buffer.
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go)
This compoent is well complimented with [Reflow][reflow] for ANSI-aware
indenting and text wrapping.
[reflow]: https://github.com/muesli/reflow
⚠️ This project is a pre-release! Check back later.
## License ## License
[MIT](https://github.com/charmbracelet/teaparty/raw/master/LICENSE) [MIT](https://github.com/charmbracelet/teaparty/raw/master/LICENSE)
***
A [Charm](https://charm.sh) project.
<img alt="the Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400">
Charm热爱开源!

10
go.mod
View File

@@ -3,9 +3,9 @@ module github.com/charmbracelet/bubbles
go 1.13 go 1.13
require ( require (
github.com/charmbracelet/bubbletea v0.8.0 github.com/atotto/clipboard v0.1.2
github.com/mattn/go-runewidth v0.0.9 // indirect github.com/charmbracelet/bubbletea v0.12.0
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 github.com/mattn/go-runewidth v0.0.9
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect github.com/muesli/termenv v0.7.4
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 // indirect
) )

33
go.sum
View File

@@ -1,5 +1,9 @@
github.com/charmbracelet/bubbletea v0.8.0 h1:ruZFaFF+2kgCI1IwNG40KTYDW5ZvE2+hPy4odlBdUko= github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
github.com/charmbracelet/bubbletea v0.8.0/go.mod h1:DzNhKkQQJI30eb+kBcaOs1+z86zTSqcMgSHoFY+uCsg= github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.12.0 h1:/pUHp1GWRDyK1TJAWkXrnRH1u8Xc5076oH/J0NHxH+M=
github.com/charmbracelet/bubbletea v0.12.0/go.mod h1:3gZkYELUOiEUOp0bTInkxguucy/xRbGSOcbMs1geLxg=
github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac=
@@ -8,21 +12,22 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 h1:AfshZBlqAwhCZ27NJ1aPlMcPBihF1squ1GpaollhLQk= github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw=
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU= github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8=
github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03/go.mod h1:Z9+Ul5bCbBKnbCvdOWbLqTHhJiYV414CURZJba6L8qA= github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 h1:cg5LA/zNPRzIXIWSCxQW10Rvpy94aQh3LT/ShoCpkHw= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee h1:4yd7jl+vXjalO5ztz6Vc1VADv+S/80LGJmyl1ROJ2AI=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM=
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o= golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 h1:9UQO31fZ+0aKQOFldThf7BKPMJTiBfWycGh/u3UoO88=
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -13,7 +13,7 @@ import (
// Type specifies the way we render pagination. // Type specifies the way we render pagination.
type Type int type Type int
// Pagination rendering options // Pagination rendering options.
const ( const (
Arabic Type = iota Arabic Type = iota
Dots Dots
@@ -21,17 +21,18 @@ const (
// Model is the Tea model for this user interface. // Model is the Tea model for this user interface.
type Model struct { type Model struct {
Type Type Type Type
Page int Page int
PerPage int PerPage int
TotalPages int TotalPages int
ActiveDot string ActiveDot string
InactiveDot string InactiveDot string
ArabicFormat string ArabicFormat string
UseLeftRightKeys bool UsePgUpPgDownKeys bool
UseUpDownKeys bool UseLeftRightKeys bool
UseHLKeys bool UseUpDownKeys bool
UseJKKeys bool UseHLKeys bool
UseJKKeys bool
} }
// SetTotalPages is a helper function for calculatng the total number of pages // SetTotalPages is a helper function for calculatng the total number of pages
@@ -98,17 +99,18 @@ func (m Model) OnLastPage() bool {
// NewModel creates a new model with defaults. // NewModel creates a new model with defaults.
func NewModel() Model { func NewModel() Model {
return Model{ return Model{
Type: Arabic, Type: Arabic,
Page: 0, Page: 0,
PerPage: 1, PerPage: 1,
TotalPages: 1, TotalPages: 1,
ActiveDot: "•", ActiveDot: "•",
InactiveDot: "○", InactiveDot: "○",
ArabicFormat: "%d/%d", ArabicFormat: "%d/%d",
UseLeftRightKeys: true, UsePgUpPgDownKeys: true,
UseUpDownKeys: false, UseLeftRightKeys: true,
UseHLKeys: true, UseUpDownKeys: false,
UseJKKeys: false, UseHLKeys: true,
UseJKKeys: false,
} }
} }
@@ -116,6 +118,14 @@ func NewModel() Model {
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
if m.UsePgUpPgDownKeys {
switch msg.String() {
case "pgup":
m.PrevPage()
case "pgdown":
m.NextPage()
}
}
if m.UseLeftRightKeys { if m.UseLeftRightKeys {
switch msg.String() { switch msg.String() {
case "left": case "left":
@@ -154,11 +164,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
} }
// View renders the pagination to a string. // View renders the pagination to a string.
func View(model tea.Model) string { func View(m Model) string {
m, ok := model.(Model)
if !ok {
return "could not perform assertion on model"
}
switch m.Type { switch m.Type {
case Dots: case Dots:
return dotsView(m) return dotsView(m)

View File

@@ -7,25 +7,17 @@ import (
"github.com/muesli/termenv" "github.com/muesli/termenv"
) )
const (
defaultFPS = time.Second / 10
)
// Spinner is a set of frames used in animating the spinner. // Spinner is a set of frames used in animating the spinner.
type Spinner = int type Spinner = []string
// Available types of spinners
const (
Line Spinner = iota
Dot
)
const (
defaultFPS = 9
)
var ( var (
// Spinner frames // Some spinners to choose from. You could also make your own.
spinners = map[Spinner][]string{ Line = Spinner([]string{"|", "/", "-", "\\"})
Line: {"|", "/", "-", "\\"}, Dot = Spinner([]string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "", "", "⣯ ", "⣷ "})
Dot: {"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
}
color = termenv.ColorProfile().Color color = termenv.ColorProfile().Color
) )
@@ -35,10 +27,10 @@ var (
type Model struct { type Model struct {
// Type is the set of frames to use. See Spinner. // Type is the set of frames to use. See Spinner.
Type Spinner Frames Spinner
// FPS is the speed at which the ticker should tick // FPS is the speed at which the ticker should tick.
FPS int FPS time.Duration
// ForegroundColor sets the background color of the spinner. It can be a // ForegroundColor sets the background color of the spinner. It can be a
// hex code or one of the 256 ANSI colors. If the terminal emulator can't // hex code or one of the 256 ANSI colors. If the terminal emulator can't
@@ -52,67 +44,129 @@ type Model struct {
// (per github.com/muesli/termenv). // (per github.com/muesli/termenv).
BackgroundColor string BackgroundColor string
// CustomMsgFunc can be used to a custom message on tick. This can be // MinimumLifetime is the minimum amount of time the spinner can run. Any
// useful when you have spinners in different parts of your application and // logic around this can be implemented in view that implements this
// want to differentiate between the messages for clarity and simplicity. // spinner. If HideFor is set MinimumLifetime will be added on top of
// If nil, this setting is ignored. // HideFor. In other words, if HideFor is 100ms and MinimumLifetime is
CustomMsgFunc func() tea.Msg // 200ms then MinimumLifetime will expire after 300ms.
//
// MinimumLifetime is optional.
//
// This is considered experimental and may not appear in future versions of
// this library.
MinimumLifetime time.Duration
frame int // HideFor can be used to wait to show the spinner until a certain amount
// of time has passed. This can be useful for preventing flicking when load
// times are very fast.
// Optional.
//
// This is considered experimental and may not appear in future versions of
// this library.
HideFor time.Duration
frame int
startTime time.Time
}
// Start resets resets the spinner start time. For use with MinimumLifetime and
// MinimumStartTime. Optional.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Start() {
m.startTime = time.Now()
}
// hidden returns whether or not Model.HideFor is in effect.
func (m Model) hidden() bool {
if m.startTime.IsZero() {
return false
}
if m.HideFor == 0 {
return false
}
return m.startTime.Add(m.HideFor).After(time.Now())
}
// finished returns whether Model.MinimumLifetimeReached has been met.
func (m Model) finished() bool {
if m.startTime.IsZero() {
return true
}
if m.MinimumLifetime == 0 {
return true
}
return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now())
}
// Visible returns whether or not the view should be rendered. Works in
// conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should
// use this message directly to determine whether or not to render this view in
// the parent view and whether to continue sending spin messaging in the
// parent update function.
//
// Also note that using this function is optional and generally considered for
// advanced use only. Most of the time your application logic will determine
// whether or not this view should be used.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m Model) Visible() bool {
return !m.hidden() && !m.finished()
} }
// NewModel returns a model with default values. // NewModel returns a model with default values.
func NewModel() Model { func NewModel() Model {
return Model{ return Model{
Type: Line, Frames: Line,
FPS: defaultFPS, FPS: defaultFPS,
} }
} }
// TickMsg indicates that the timer has ticked and we should render a frame. // TickMsg indicates that the timer has ticked and we should render a frame.
type TickMsg struct{} type TickMsg struct {
Time time.Time
}
// Update is the Tea update function. This will advance the spinner one frame // Update is the Tea update function. This will advance the spinner one frame
// every time it's called, regardless the message passed, so be sure the logic // every time it's called, regardless the message passed, so be sure the logic
// is setup so as not to call this Update needlessly. // is setup so as not to call this Update needlessly.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.frame++ if _, ok := msg.(TickMsg); ok {
if m.frame >= len(spinners[m.Type]) { m.frame++
m.frame = 0 if m.frame >= len(m.Frames) {
} m.frame = 0
if m.CustomMsgFunc != nil { }
return m, Tick(m) return m, Tick(m)
} }
return m, Tick(m) return m, nil
} }
// View renders the model's view. // View renders the model's view.
func View(model Model) string { func View(model Model) string {
s := spinners[model.Type] if model.frame >= len(model.Frames) {
if model.frame >= len(s) { return "error"
return "[error]"
} }
str := s[model.frame] frame := model.Frames[model.frame]
if model.ForegroundColor != "" || model.BackgroundColor != "" { if model.ForegroundColor != "" || model.BackgroundColor != "" {
return termenv. return termenv.
String(str). String(frame).
Foreground(color(model.ForegroundColor)). Foreground(color(model.ForegroundColor)).
Background(color(model.BackgroundColor)). Background(color(model.BackgroundColor)).
String() String()
} }
return str return frame
} }
// Tick is the command used to advance the spinner one frame. // Tick is the command used to advance the spinner one frame.
func Tick(model Model) tea.Cmd { func Tick(m Model) tea.Cmd {
return func() tea.Msg { return tea.Tick(m.FPS, func(t time.Time) tea.Msg {
time.Sleep(time.Second / time.Duration(model.FPS)) return TickMsg{
if model.CustomMsgFunc != nil { Time: t,
return model.CustomMsgFunc()
} }
return TickMsg{} })
}
} }

View File

@@ -5,6 +5,7 @@ import (
"time" "time"
"unicode" "unicode"
"github.com/atotto/clipboard"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth" rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv" "github.com/muesli/termenv"
@@ -15,15 +16,10 @@ const (
) )
var ( var (
// color is a helper for returning colors // color is a helper for returning colors.
color func(s string) termenv.Color = termenv.ColorProfile().Color color func(s string) termenv.Color = termenv.ColorProfile().Color
) )
// ErrMsg indicates there's been an error. We don't handle errors in the this
// package; we're expecting errors to be handle in the program that implements
// this text input.
type ErrMsg error
// Model is the Tea model for this text input element. // Model is the Tea model for this text input element.
type Model struct { type Model struct {
Err error Err error
@@ -126,6 +122,47 @@ func (m *Model) Reset() {
m.blink = false m.blink = false
} }
// Paste pastes the contents of the clipboard into the text area (if supported).
func (m *Model) Paste() {
pasteString, err := clipboard.ReadAll()
if err != nil {
m.Err = err
}
paste := []rune(pasteString)
availSpace := m.CharLimit - len(m.value)
// If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 {
return
}
// If there's not enough space to paste the whole thing cut the pasted
// runes down so they'll fit
if availSpace < len(paste) {
paste = paste[:len(paste)-availSpace]
}
// Stuff before and after the cursor
head := m.value[:m.pos]
tailSrc := m.value[m.pos:]
tail := make([]rune, len(tailSrc))
copy(tail, tailSrc)
// Insert pasted runes
for _, r := range paste {
head = append(head, r)
availSpace--
m.pos++
if m.CharLimit > 0 && availSpace <= 0 {
break
}
}
// Put it all back together
m.value = append(head, tail...)
}
// If a max width is defined, perform some logic to treat the visible area // If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling viewport. // as a horizontally scrolling viewport.
func (m *Model) handleOverflow() { func (m *Model) handleOverflow() {
@@ -135,8 +172,10 @@ func (m *Model) handleOverflow() {
return return
} }
if m.pos < m.offset { // Correct right offset if we've deleted characters
m.offsetRight = min(m.offsetRight, len(m.value))
if m.pos < m.offset {
m.offset = m.pos m.offset = m.pos
w := 0 w := 0
@@ -151,9 +190,7 @@ func (m *Model) handleOverflow() {
} }
m.offsetRight = m.offset + i m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight { } else if m.pos >= m.offsetRight {
m.offsetRight = m.pos m.offsetRight = m.pos
w := 0 w := 0
@@ -168,7 +205,6 @@ func (m *Model) handleOverflow() {
} }
m.offset = m.offsetRight - (len(runes) - 1 - i) m.offset = m.offsetRight - (len(runes) - 1 - i)
} }
} }
@@ -226,7 +262,7 @@ func (m *Model) wordRight() {
i := m.pos i := m.pos
for i < len(m.value) { for i < len(m.value) {
if unicode.IsSpace(rune(m.value[i])) { if unicode.IsSpace(m.value[i]) {
m.pos++ m.pos++
i++ i++
} else { } else {
@@ -235,7 +271,7 @@ func (m *Model) wordRight() {
} }
for i < len(m.value) { for i < len(m.value) {
if !unicode.IsSpace(rune(m.value[i])) { if !unicode.IsSpace(m.value[i]) {
m.pos++ m.pos++
i++ i++
} else { } else {
@@ -279,8 +315,10 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
fallthrough fallthrough
case tea.KeyDelete: case tea.KeyDelete:
if len(m.value) > 0 { if len(m.value) > 0 {
m.value = append(m.value[:m.pos-1], m.value[m.pos:]...) m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
m.pos-- if m.pos > 0 {
m.pos--
}
} }
case tea.KeyLeft: case tea.KeyLeft:
if msg.Alt { // alt+left arrow, back one word if msg.Alt { // alt+left arrow, back one word
@@ -317,6 +355,8 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.value = m.value[m.pos:] m.value = m.value[m.pos:]
m.pos = 0 m.pos = 0
m.offset = 0 m.offset = 0
case tea.KeyCtrlV: // ^V paste
m.Paste()
case tea.KeyRune: // input a regular character case tea.KeyRune: // input a regular character
if msg.Alt { if msg.Alt {
@@ -337,9 +377,6 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
} }
} }
case ErrMsg:
m.Err = msg
case BlinkMsg: case BlinkMsg:
m.blink = !m.blink m.blink = !m.blink
return m, Blink(m) return m, Blink(m)
@@ -351,19 +388,14 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
} }
// View renders the textinput in its current state. // View renders the textinput in its current state.
func View(model tea.Model) string { func View(m Model) string {
m, ok := model.(Model)
if !ok {
return "could not perform assertion on model"
}
// Placeholder text // Placeholder text
if m.value == nil && m.Placeholder != "" { if len(m.value) == 0 && m.Placeholder != "" {
return placeholderView(m) return placeholderView(m)
} }
value := m.value[m.offset:m.offsetRight] value := m.value[m.offset:m.offsetRight]
pos := m.pos - m.offset pos := max(0, m.pos-m.offset)
v := m.colorText(string(value[:pos])) v := m.colorText(string(value[:pos]))
@@ -391,7 +423,7 @@ func View(model tea.Model) string {
return m.Prompt + v return m.Prompt + v
} }
// placeholderView // placeholderView.
func placeholderView(m Model) string { func placeholderView(m Model) string {
var ( var (
v string v string
@@ -400,10 +432,7 @@ func placeholderView(m Model) string {
// Cursor // Cursor
if m.blink && m.PlaceholderColor != "" { if m.blink && m.PlaceholderColor != "" {
v += cursorView( v += cursorView(m.colorPlaceholder(p[:1]), m)
m.colorPlaceholder(p[:1]),
m,
)
} else { } else {
v += cursorView(p[:1], m) v += cursorView(p[:1], m)
} }

View File

@@ -1,147 +1,369 @@
package viewport package viewport
import ( import (
"math"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
const (
spacebar = " "
)
// MODEL // MODEL
type Model struct { type Model struct {
Err error
Width int Width int
Height int Height int
Y int
// YOffset is the vertical scroll position.
YOffset int
// YPosition is the position of the viewport in relation to the terminal
// window. It's used in high performance rendering.
YPosition int
// HighPerformanceRendering bypasses the normal Bubble Tea renderer to
// provide higher performance rendering. Most of the time the normal Bubble
// Tea rendering methods will suffice, but if you're passing content with
// a lot of ANSI escape codes you may see improved rendering in certain
// terminals with this enabled.
//
// This should only be used in program occupying the entire terminal,
// which is usually via the alternate screen buffer.
HighPerformanceRendering bool
lines []string lines []string
} }
// AtTop returns whether or not the viewport is in the very top position.
func (m Model) AtTop() bool {
return m.YOffset <= 0
}
// AtBottom returns whether or not the viewport is at or past the very bottom
// position.
func (m Model) AtBottom() bool {
return m.YOffset >= len(m.lines)-1-m.Height
}
// PastBottom returns whether or not the viewport is scrolled beyond the last
// line. This can happen when adjusting the viewport height.
func (m Model) PastBottom() bool {
return m.YOffset > len(m.lines)-1-m.Height
}
// Scrollpercent returns the amount scrolled as a float between 0 and 1. // Scrollpercent returns the amount scrolled as a float between 0 and 1.
func (m Model) ScrollPercent() float64 { func (m Model) ScrollPercent() float64 {
if m.Height >= len(m.lines) { if m.Height >= len(m.lines) {
return 1.0 return 1.0
} }
y := float64(m.Y) y := float64(m.YOffset)
h := float64(m.Height) h := float64(m.Height)
t := float64(len(m.lines)) t := float64(len(m.lines) - 1)
return y / (t - h) v := y / (t - h)
return math.Max(0.0, math.Min(1.0, v))
} }
// SetContent set the pager's text content. // SetContent set the pager's text content. For high performance rendering the
// Sync command should also be called.
func (m *Model) SetContent(s string) { func (m *Model) SetContent(s string) {
s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings
m.lines = strings.Split(s, "\n") m.lines = strings.Split(s, "\n")
} }
// NewModel creates a new pager model. Pass the dimensions of the pager. // Return the lines that should currently be visible in the viewport.
func NewModel(width, height int) Model { func (m Model) visibleLines() (lines []string) {
return Model{ if len(m.lines) > 0 {
Width: width, top := max(0, m.YOffset)
Height: height, bottom := min(len(m.lines), m.YOffset+m.Height)
lines = m.lines[top:bottom]
} }
return lines
} }
// ViewDown moves the view down by the number of lines in the viewport. // ViewDown moves the view down by the number of lines in the viewport.
// Basically, "page down". // Basically, "page down".
func (m *Model) ViewDown() { func (m *Model) ViewDown() []string {
m.Y = min(len(m.lines)-m.Height, m.Y+m.Height) if m.AtBottom() {
return nil
}
m.YOffset = min(
m.YOffset+m.Height, // target
len(m.lines)-1-m.Height, // fallback
)
return m.visibleLines()
} }
// ViewUp moves the view up by one height of the viewport. Basically, "page up". // ViewUp moves the view up by one height of the viewport. Basically, "page up".
func (m *Model) ViewUp() { func (m *Model) ViewUp() []string {
m.Y = max(0, m.Y-m.Height) if m.AtTop() {
} return nil
}
// HalfViewUp moves the view up by half the height of the viewport. m.YOffset = max(
func (m *Model) HalfViewUp() { m.YOffset-m.Height, // target
m.Y = max(0, m.Y-m.Height/2) 0, // fallback
)
return m.visibleLines()
} }
// HalfViewDown moves the view down by half the height of the viewport. // HalfViewDown moves the view down by half the height of the viewport.
func (m *Model) HalfViewDown() { func (m *Model) HalfViewDown() (lines []string) {
m.Y = min(len(m.lines)-m.Height, m.Y+m.Height/2) if m.AtBottom() {
return nil
}
m.YOffset = min(
m.YOffset+m.Height/2, // target
len(m.lines)-1-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset+m.Height/2, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// LineDown moves the view up by the given number of lines. // HalfViewUp moves the view up by half the height of the viewport.
func (m *Model) LineDown(n int) { func (m *Model) HalfViewUp() (lines []string) {
m.Y = min(len(m.lines)-m.Height, m.Y+n) if m.AtTop() {
return nil
}
m.YOffset = max(
m.YOffset-m.Height/2, // target
0, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height/2, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
} }
// LineDown moves the view down by the given number of lines. // LineDown moves the view down by the given number of lines.
func (m *Model) LineUp(n int) { func (m *Model) LineDown(n int) (lines []string) {
m.Y = max(0, m.Y-n) if m.AtBottom() || n == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we actually have left before we reach
// the bottom.
maxDelta := (len(m.lines) - 1) - (m.YOffset + m.Height) // number of lines - viewport bottom edge
n = min(n, maxDelta)
m.YOffset = min(
m.YOffset+n, // target
len(m.lines)-1-m.Height, // fallback
)
if len(m.lines) > 0 {
top := max(m.YOffset+m.Height-n, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// LineUp moves the view down by the given number of lines. Returns the new
// lines to show.
func (m *Model) LineUp(n int) (lines []string) {
if m.AtTop() || n == 0 {
return nil
}
// Make sure the number of lines by which we're going to scroll isn't
// greater than the number of lines we are from the top.
n = min(n, m.YOffset)
m.YOffset = max(m.YOffset-n, 0)
if len(m.lines) > 0 {
top := max(0, m.YOffset)
bottom := min(m.YOffset+n, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) {
if m.AtTop() {
return nil
}
m.YOffset = 0
if len(m.lines) > 0 {
top := m.YOffset
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
lines = m.lines[top:bottom]
}
return lines
}
// GotoTop sets the viewport to the bottom position.
func (m *Model) GotoBottom() (lines []string) {
m.YOffset = max(len(m.lines)-1-m.Height, 0)
if len(m.lines) > 0 {
top := m.YOffset
bottom := max(len(m.lines)-1, 0)
lines = m.lines[top:bottom]
}
return lines
}
// COMMANDS
// Sync tells the renderer where the viewport will be located and requests
// a render of the current state of the viewport. It should be called for the
// first render and after a window resize.
//
// For high performance rendering only.
func Sync(m Model) tea.Cmd {
if len(m.lines) == 0 {
return nil
}
// TODO: we should probably use m.visibleLines() rather than these two
// expressions.
top := max(m.YOffset, 0)
bottom := min(m.YOffset+m.Height, len(m.lines)-1)
return tea.SyncScrollArea(
m.lines[top:bottom],
m.YPosition,
m.YPosition+m.Height,
)
}
// ViewDown is a high performance command that moves the viewport up by a given
// numer of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example:
//
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
//
func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height)
}
// ViewUp is a high performance command the moves the viewport down by a given
// number of lines height. Use Model.ViewDown to get the lines that should be
// rendered.
func ViewUp(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height)
} }
// UPDATE // UPDATE
// Update runs the update loop with default keybindings. To define your own // Update runs the update loop with default keybindings similar to popular
// keybindings use the methods on Model. // pagers. To define your own keybindings use the methods on Model (i.e.
// Model.LineDown()) and define your own update function.
func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
switch msg := msg.(type) { var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
// Down one page // Down one page
case "pgdown": case "pgdown", spacebar, "f":
fallthrough lines := m.ViewDown()
case " ": // spacebar if m.HighPerformanceRendering {
fallthrough cmd = ViewDown(m, lines)
case "f": }
m.ViewDown()
return m, nil
// Up one page // Up one page
case "pgup": case "pgup", "b":
fallthrough lines := m.ViewUp()
case "b": if m.HighPerformanceRendering {
m.ViewUp() cmd = ViewUp(m, lines)
return m, nil }
// Down half page // Down half page
case "d": case "d":
m.HalfViewDown() lines := m.HalfViewDown()
return m, nil if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
// Up half page // Up half page
case "u": case "u":
m.HalfViewUp() lines := m.HalfViewUp()
return m, nil if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
// Down one line // Down one line
case "down": case "down", "j":
fallthrough lines := m.LineDown(1)
case "j": if m.HighPerformanceRendering {
m.LineDown(1) cmd = ViewDown(m, lines)
return m, nil }
// Up one line // Up one line
case "up": case "up", "k":
fallthrough lines := m.LineUp(1)
case "k": if m.HighPerformanceRendering {
m.LineUp(1) cmd = ViewUp(m, lines)
return m, nil }
}
case tea.MouseMsg:
switch msg.Type {
case tea.MouseWheelUp:
lines := m.LineUp(3)
if m.HighPerformanceRendering {
cmd = ViewUp(m, lines)
}
case tea.MouseWheelDown:
lines := m.LineDown(3)
if m.HighPerformanceRendering {
cmd = ViewDown(m, lines)
}
} }
} }
return m, nil return m, cmd
} }
// VIEW // VIEW
// View renders the viewport into a string. // View renders the viewport into a string.
func View(m Model) string { func View(m Model) string {
if m.Err != nil { if m.HighPerformanceRendering {
return m.Err.Error() // Just send newlines since we're doing to be rendering the actual
// content seprately. We still need send something that equals the
// height of this view so that the Bubble Tea standard renderer can
// position anything below this view properly.
return strings.Repeat("\n", m.Height-1)
} }
var lines []string lines := m.visibleLines()
if len(m.lines) > 0 {
top := max(0, m.Y)
bottom := min(len(m.lines), m.Y+m.Height)
lines = m.lines[top:bottom]
}
// Fill empty space with newlines // Fill empty space with newlines
extraLines := "" extraLines := ""