23 Commits

Author SHA1 Message Date
Christian Rocha
6d6149cea8 fix(progress): update progress bar threshold method per changes in d897463 2022-03-31 15:15:59 -04:00
Christian Rocha
00d61decf4 Add minimum percent change needed to trigger an animation in progress 2022-03-31 15:15:59 -04:00
Christian Rocha
430b7b5d36 Remove provisional spinner lifetime stuff
The design was overly complicated, especially for such subtle benefits.
2022-03-31 10:59:39 -04:00
Christian Rocha
00ec90b59f docs(list): fix typo in doc comment 2022-03-30 15:24:24 -04:00
Carlos A Becker
aa0744fd8d docs: godoc
Signed-off-by: Carlos A Becker <caarlos0@gmail.com>
2022-03-30 15:24:24 -04:00
Carlos A Becker
cf1fe5f9ce fix: type name
Signed-off-by: Carlos A Becker <caarlos0@gmail.com>
2022-03-30 15:24:24 -04:00
Carlos A Becker
6c18900279 feat: allow custom filter functions
Signed-off-by: Carlos A Becker <caarlos0@gmail.com>
2022-03-30 15:24:24 -04:00
Christian Rocha
d897463138 chore(progress): Percent() now return progress as presented visually
Prior to this change Percent() returned the target progress to which the
progress bar was animating.
2022-03-30 14:48:46 -04:00
Ayman Bagabas
88562515cf fix(list): rendering empty list
Fixes: 4e18245481 ("Add list bubble")
2022-03-02 17:38:35 -05:00
Austin Schey
e349920524 Add Blink method to textinput to return blink state 2022-03-01 07:35:21 -05:00
Christian Rocha
64b9e0582f docs: add more libs to Additional Bubbles in the README 2022-02-25 14:47:11 -05:00
Christian Rocha
057f7b9a4d Add evertras/bubble-table to 'Additional Bubbles' section in README
Items in that section are now organized alphabetically.
2022-02-17 10:35:07 -05:00
lorenries
1d489252fe fix(list): disable quit keybinding while filtering 2022-02-08 14:42:03 -05:00
treilik
06358c35f9 Add bubblelister and bubbleboxer to "additional bubbles" in the README (#113) 2022-02-06 07:04:52 +01:00
mirko
005233b529 Improve insert item documentation (#115) 2022-02-03 02:58:08 +01:00
Ayman Bagabas
18d25458da fix(list): DisableQuitKeybinding is ignored after updating the list (#108) 2022-01-27 13:09:53 -05:00
Ayman Bagabas
db97ac515d feat: sync bubbles with git.charm.sh 2022-01-24 17:08:23 -05:00
Christian Muehlhaeuser
200f95759b Fix key binding documentation
Fixes #105.
2022-01-24 15:09:43 +01:00
Christian Rocha
7ecce3fb97 Ignore width/height settings in viewport's style settings
The Lip Gloss width and height settings compete with the main
width/height settings and can result in funny rendering and generally
cause confusion.
2022-01-20 13:23:58 -05:00
Christian Rocha
746834a7ce Add safety check in textinput's clamp 2022-01-20 13:23:58 -05:00
Christian Rocha
fd306528f9 Rename var in deleteWordRight in textinput for additional clarity 2022-01-20 13:23:58 -05:00
Christian Rocha
a4dc540f3d Re-add panic guard in deleteWordLeft in textinput, just in case 2022-01-20 13:23:58 -05:00
IllusionMan1212
151d1026dd fix(textinput): use old cursor pos and simplify logic 2022-01-19 18:31:55 -05:00
8 changed files with 144 additions and 144 deletions

12
.github/workflows/soft-serve.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: soft-serve
on:
push:
branches:
- master
jobs:
soft-serve:
uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main
secrets:
ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}"

View File

@@ -149,8 +149,8 @@ var DefaultKeyMap = KeyMap{
key.WithHelp("↑/k", "move up"), // corresponding help text key.WithHelp("↑/k", "move up"), // corresponding help text
), ),
Down: key.NewBinding( Down: key.NewBinding(
WithKeys("j", "down"), key.WithKeys("j", "down"),
WithHelp("↓/j", "move down"), key.WithHelp("↓/j", "move down"),
), ),
} }
@@ -171,13 +171,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
## Additional Bubbles ## Additional Bubbles
* [promptkit](https://github.com/erikgeiser/promptkit): A collection of common * [76creates/stickers](https://github.com/76creates/stickers): Responsive
prompts for cases like selection, text input, and confirmation. Each prompt flexbox and table components.
comes with sensible defaults, remappable keybindings, any many customization * [calyptia/go-bubble-table](https://github.com/calyptia/go-bubble-table): An
options. interactive, customizable, scrollable table component.
* [erikgeiser/promptkit](https://github.com/erikgeiser/promptkit): A collection
of common prompts for cases like selection, text input, and confirmation.
Each prompt comes with sensible defaults, remappable keybindings, any many
customization options.
* [evertras/bubble-table](https://github.com/Evertras/bubble-table): Interactive,
customizable, paginated tables.
* [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose * [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose
bubbles. Inputs with validation, menu selection, a modified progressbar, and bubbles. Inputs with validation, menu selection, a modified progressbar, and
so on. so on.
* [treilik/bubbleboxer](https://github.com/treilik/bubbleboxer): Layout
multiple bubbles side-by-side in a layout-tree.
* [treilik/bubblelister](https://github.com/treilik/bubblelister): An alternate
list that is scrollable without pagination and has the ability to contain
other bubbles as list items.
If youve built a Bubble you think should be listed here, If youve built a Bubble you think should be listed here,
[let us know](mailto:vt100@charm.sh). [let us know](mailto:vt100@charm.sh).

View File

@@ -13,8 +13,8 @@
// key.WithHelp("↑/k", "move up"), // corresponding help text // key.WithHelp("↑/k", "move up"), // corresponding help text
// ), // ),
// Down: key.NewBinding( // Down: key.NewBinding(
// WithKeys("j", "down"), // key.WithKeys("j", "down"),
// WithHelp("↓/j", "move down"), // key.WithHelp("↓/j", "move down"),
// ), // ),
// } // }
// //

View File

@@ -71,6 +71,34 @@ func (f filteredItems) items() []Item {
// message should be routed to Update for processing. // message should be routed to Update for processing.
type FilterMatchesMsg []filteredItem type FilterMatchesMsg []filteredItem
// FilterFunc takes a term and a list of strings to search through
// (defined by Item#FilterValue).
// It should return a sorted list of ranks.
type FilterFunc func(string, []string) []Rank
// Rank defines a rank for a given item.
type Rank struct {
// The index of the item in the original input.
Index int
// Indices of the actual word that were matched against the filter term.
MatchedIndexes []int
}
// DefaultFilter uses the sahilm/fuzzy to filter through the list.
// This is set by default.
func DefaultFilter(term string, targets []string) []Rank {
var ranks fuzzy.Matches = fuzzy.Find(term, targets)
sort.Stable(ranks)
result := make([]Rank, len(ranks))
for i, r := range ranks {
result[i] = Rank{
Index: r.Index,
MatchedIndexes: r.MatchedIndexes,
}
}
return result
}
type statusMessageTimeoutMsg struct{} type statusMessageTimeoutMsg struct{}
// FilterState describes the current filtering state on the model. // FilterState describes the current filtering state on the model.
@@ -107,6 +135,11 @@ type Model struct {
// Key mappings for navigating the list. // Key mappings for navigating the list.
KeyMap KeyMap KeyMap KeyMap
// Filter is used to filter the list.
Filter FilterFunc
disableQuitKeybindings bool
// Additional key mappings for the short and full help views. This allows // Additional key mappings for the short and full help views. This allows
// you to add additional key mappings to the help menu without // you to add additional key mappings to the help menu without
// re-implementing the help component. Of course, you can also disable the // re-implementing the help component. Of course, you can also disable the
@@ -171,6 +204,7 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
showHelp: true, showHelp: true,
filteringEnabled: true, filteringEnabled: true,
KeyMap: DefaultKeyMap(), KeyMap: DefaultKeyMap(),
Filter: DefaultFilter,
Styles: styles, Styles: styles,
Title: "List", Title: "List",
FilterInput: filterInput, FilterInput: filterInput,
@@ -324,7 +358,8 @@ func (m *Model) SetItem(index int, item Item) tea.Cmd {
return cmd return cmd
} }
// Insert an item at the given index. This returns a command. // Insert an item at the given index. If index is out of the upper bound, the
// item will be appended. This returns a command.
func (m *Model) InsertItem(index int, item Item) tea.Cmd { func (m *Model) InsertItem(index int, item Item) tea.Cmd {
var cmd tea.Cmd var cmd tea.Cmd
m.items = insertItemIntoSlice(m.items, item, index) m.items = insertItemIntoSlice(m.items, item, index)
@@ -520,6 +555,7 @@ func (m *Model) StopSpinner() {
// Helper for disabling the keybindings used for quitting, incase you want to // Helper for disabling the keybindings used for quitting, incase you want to
// handle this elsewhere in your application. // handle this elsewhere in your application.
func (m *Model) DisableQuitKeybindings() { func (m *Model) DisableQuitKeybindings() {
m.disableQuitKeybindings = true
m.KeyMap.Quit.SetEnabled(false) m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ForceQuit.SetEnabled(false) m.KeyMap.ForceQuit.SetEnabled(false)
} }
@@ -602,7 +638,7 @@ func (m *Model) updateKeybindings() {
m.KeyMap.ClearFilter.SetEnabled(false) m.KeyMap.ClearFilter.SetEnabled(false)
m.KeyMap.CancelWhileFiltering.SetEnabled(true) m.KeyMap.CancelWhileFiltering.SetEnabled(true)
m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "")
m.KeyMap.Quit.SetEnabled(true) m.KeyMap.Quit.SetEnabled(false)
m.KeyMap.ShowFullHelp.SetEnabled(false) m.KeyMap.ShowFullHelp.SetEnabled(false)
m.KeyMap.CloseFullHelp.SetEnabled(false) m.KeyMap.CloseFullHelp.SetEnabled(false)
@@ -622,7 +658,7 @@ func (m *Model) updateKeybindings() {
m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied)
m.KeyMap.CancelWhileFiltering.SetEnabled(false) m.KeyMap.CancelWhileFiltering.SetEnabled(false)
m.KeyMap.AcceptWhileFiltering.SetEnabled(false) m.KeyMap.AcceptWhileFiltering.SetEnabled(false)
m.KeyMap.Quit.SetEnabled(true) m.KeyMap.Quit.SetEnabled(!m.disableQuitKeybindings)
if m.Help.ShowAll { if m.Help.ShowAll {
m.KeyMap.ShowFullHelp.SetEnabled(true) m.KeyMap.ShowFullHelp.SetEnabled(true)
@@ -1078,7 +1114,7 @@ func (m Model) populatedView() string {
if m.filterState == Filtering { if m.filterState == Filtering {
return "" return ""
} }
m.Styles.NoItems.Render("No items found.") return m.Styles.NoItems.Render("No items found.")
} }
if len(items) > 0 { if len(items) > 0 {
@@ -1129,11 +1165,8 @@ func filterItems(m Model) tea.Cmd {
targets = append(targets, t.FilterValue()) targets = append(targets, t.FilterValue())
} }
var ranks fuzzy.Matches = fuzzy.Find(m.FilterInput.Value(), targets)
sort.Stable(ranks)
filterMatches := []filteredItem{} filterMatches := []filteredItem{}
for _, r := range ranks { for _, r := range m.Filter(m.FilterInput.Value(), targets) {
filterMatches = append(filterMatches, filteredItem{ filterMatches = append(filterMatches, filteredItem{
item: items[r.Index], item: items[r.Index],
matches: r.MatchedIndexes, matches: r.MatchedIndexes,

View File

@@ -31,10 +31,11 @@ func nextID() int {
} }
const ( const (
fps = 60 fps = 60
defaultWidth = 40 defaultWidth = 40
defaultFrequency = 18.0 defaultFrequency = 18.0
defaultDamping = 1.0 defaultDamping = 1.0
defaultAnimThreshold = 0.08
) )
var color func(string) termenv.Color = termenv.ColorProfile().Color var color func(string) termenv.Color = termenv.ColorProfile().Color
@@ -110,6 +111,14 @@ func WithSpringOptions(frequency, damping float64) Option {
} }
} }
// WithAnimationThreshold sets the percent chagne threshold necessary to
// trigger an animated transition.
func WithAnimationThreshold(ratio float64) Option {
return func(m *Model) {
m.SetAnimationThreshold(ratio)
}
}
// FrameMsg indicates that an animation step should occur. // FrameMsg indicates that an animation step should occur.
type FrameMsg struct { type FrameMsg struct {
id int id int
@@ -141,13 +150,17 @@ type Model struct {
PercentFormat string // a fmt string for a float PercentFormat string // a fmt string for a float
PercentageStyle lipgloss.Style PercentageStyle lipgloss.Style
// Members for animated transitions. // Settings for animated transitions.
spring harmonica.Spring spring harmonica.Spring
springCustomized bool springCustomized bool
percent float64 percentShown float64 // percent currently displaying
targetPercent float64 targetPercent float64 // percent to which we're animating
velocity float64 velocity float64
// The amount of change required to trigger an animated transition. Should
// be a float between 0 and 1.
animThreshold float64
// Gradient settings // Gradient settings
useRamp bool useRamp bool
rampColorA colorful.Color rampColorA colorful.Color
@@ -203,12 +216,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
// If we've more or less reached equilibrium, stop updating. // If we've more or less reached equilibrium, stop updating.
dist := math.Abs(m.percent - m.targetPercent) dist := math.Abs(m.percentShown - m.targetPercent)
if dist < 0.001 && m.velocity < 0.01 { if dist < 0.001 && m.velocity < 0.01 {
return m, nil return m, nil
} }
m.percent, m.velocity = m.spring.Update(m.percent, m.velocity, m.targetPercent) m.percentShown, m.velocity = m.spring.Update(m.percentShown, m.velocity, m.targetPercent)
return m, m.nextFrame() return m, m.nextFrame()
default: default:
@@ -224,7 +237,7 @@ func (m *Model) SetSpringOptions(frequency, damping float64) {
m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping) m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping)
} }
// Percent returns the current percentage state of the model. This is only // Percent returns the current visible percentage on the model. This is only
// relevant when you're animating the progress bar. // relevant when you're animating the progress bar.
// //
// If you're rendering with ViewAs you won't need this. // If you're rendering with ViewAs you won't need this.
@@ -237,7 +250,14 @@ func (m Model) Percent() float64 {
// //
// If you're rendering with ViewAs you won't need this. // If you're rendering with ViewAs you won't need this.
func (m *Model) SetPercent(p float64) tea.Cmd { func (m *Model) SetPercent(p float64) tea.Cmd {
m.targetPercent = math.Max(0, math.Min(1, p)) // If the value is at or below the animation threshold, don't animate
if math.Abs(p-m.percentShown) <= m.animThreshold {
m.percentShown = asRatio(p)
m.targetPercent = asRatio(p)
return nil
}
m.targetPercent = asRatio(p)
m.tag++ m.tag++
return m.nextFrame() return m.nextFrame()
} }
@@ -258,10 +278,22 @@ func (m *Model) DecrPercent(v float64) tea.Cmd {
return m.SetPercent(m.Percent() - v) return m.SetPercent(m.Percent() - v)
} }
// SetAnimationThreshold sets the percent chagne threshold necessary to trigger
// an animated transition.
func (m *Model) SetAnimationThreshold(v float64) {
m.animThreshold = asRatio(v)
}
// AnimationThreshold returns the percent change necessary to trigger an
// animated transition.
func (m *Model) AnimationThreshold() float64 {
return m.animThreshold
}
// View renders the an animated progress bar in its current state. To render // View renders the an animated progress bar in its current state. To render
// a static progress bar based on your own calculations use ViewAs instead. // a static progress bar based on your own calculations use ViewAs instead.
func (m Model) View() string { func (m Model) View() string {
return m.ViewAs(m.percent) return m.ViewAs(m.percentShown)
} }
// ViewAs renders the progress bar with a given percentage. // ViewAs renders the progress bar with a given percentage.
@@ -351,3 +383,7 @@ func min(a, b int) int {
} }
return b return b
} }
func asRatio(v float64) float64 {
return math.Max(math.Min(v, 1), 0)
}

View File

@@ -1,13 +1,11 @@
package spinner package spinner
import ( import (
"strings"
"sync" "sync"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/ansi"
) )
// Internal ID management for text inputs. Necessary for blink integrity when // Internal ID management for text inputs. Necessary for blink integrity when
@@ -85,114 +83,17 @@ type Model struct {
// https://github.com/charmbracelet/lipgloss // https://github.com/charmbracelet/lipgloss
Style lipgloss.Style Style lipgloss.Style
// MinimumLifetime is the minimum amount of time the spinner can run. Any
// logic around this can be implemented in view that implements this
// spinner. If HideFor is set MinimumLifetime will be added on top of
// HideFor. In other words, if HideFor is 100ms and MinimumLifetime is
// 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
// 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 frame int
startTime time.Time startTime time.Time
id int id int
tag int tag int
} }
// Start resets resets the spinner start time. For use with MinimumLifetime and
// MinimumStartTime. Optional.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Start() {
m.startTime = time.Now()
}
// Finish sets the internal timer to a completed state so long as the spinner
// isn't flagged to be showing. If it is showing, finish has no effect. The
// idea here is that you call Finish if your operation has completed and, if
// the spinner isn't showing yet (by virtue of HideFor) then Visible() doesn't
// show the spinner at all.
//
// This is intended to work in conjunction with MinimumLifetime and
// MinimumStartTime, is completely optional.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m *Model) Finish() {
if m.hidden() {
m.startTime = time.Time{}
}
}
// ID returns the spinner's unique ID. // ID returns the spinner's unique ID.
func (m Model) ID() int { func (m Model) ID() int {
return m.id return m.id
} }
// advancedMode returns whether or not the user is making use of HideFor and
// MinimumLifetime properties.
func (m Model) advancedMode() bool {
return m.HideFor > 0 && m.MinimumLifetime > 0
}
// 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 the minimum lifetime of this spinner has been
// exceeded.
func (m Model) finished() bool {
if m.startTime.IsZero() || 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 method 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.
//
// This function is optional and generally considered for advanced use only.
// Most of the time your application logic will obviate the need for this
// method.
//
// This is considered experimental and may not appear in future versions of
// this library.
func (m Model) Visible() bool {
return !m.hidden() && !m.finished()
}
// New returns a model with default values. // New returns a model with default values.
func New() Model { func New() Model {
return Model{ return Model{
@@ -250,16 +151,7 @@ func (m Model) View() string {
return "(error)" return "(error)"
} }
frame := m.Spinner.Frames[m.frame] return m.Style.Render(m.Spinner.Frames[m.frame])
// If we're using the fine-grained hide/show spinner rules and those rules
// deem that the spinner should be hidden, draw an empty space in place of
// the spinner.
if m.advancedMode() && !m.Visible() {
frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame))
}
return m.Style.Render(frame)
} }
// Tick is the command used to advance the spinner one frame. Use this command // Tick is the command used to advance the spinner one frame. Use this command

View File

@@ -203,6 +203,11 @@ func (m Model) Cursor() int {
return m.pos return m.pos
} }
// Blink returns whether or not to draw the cursor.
func (m Model) Blink() bool {
return m.blink
}
// SetCursor moves the cursor to the given position. If the position is // SetCursor moves the cursor to the given position. If the position is
// out of bounds the cursor will be moved to the start or end accordingly. // out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) { func (m *Model) SetCursor(pos int) {
@@ -412,6 +417,11 @@ func (m *Model) deleteWordLeft() bool {
return m.deleteBeforeCursor() return m.deleteBeforeCursor()
} }
// Linter note: it's critical that we acquire the initial cursor position
// here prior to altering it via SetCursor() below. As such, moving this
// call into the corresponding if clause does not apply here.
oldPos := m.pos //nolint:ifshort
blink := m.setCursor(m.pos - 1) blink := m.setCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 { if m.pos <= 0 {
@@ -433,10 +443,10 @@ func (m *Model) deleteWordLeft() bool {
} }
} }
if m.pos > len(m.value) { if oldPos > len(m.value) {
m.value = m.value[:m.pos] m.value = m.value[:m.pos]
} else { } else {
m.value = append(m.value[:m.pos], m.value[m.pos:]...) m.value = append(m.value[:m.pos], m.value[oldPos:]...)
} }
return blink return blink
@@ -454,7 +464,7 @@ func (m *Model) deleteWordRight() bool {
return m.deleteAfterCursor() return m.deleteAfterCursor()
} }
i := m.pos oldPos := m.pos
m.setCursor(m.pos + 1) m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor // ignore series of whitespace after cursor
@@ -474,12 +484,12 @@ func (m *Model) deleteWordRight() bool {
} }
if m.pos > len(m.value) { if m.pos > len(m.value) {
m.value = m.value[:i] m.value = m.value[:oldPos]
} else { } else {
m.value = append(m.value[:i], m.value[m.pos:]...) m.value = append(m.value[:oldPos], m.value[m.pos:]...)
} }
return m.setCursor(i) return m.setCursor(oldPos)
} }
// wordLeft moves the cursor one word to the left. Returns whether or not the // wordLeft moves the cursor one word to the left. Returns whether or not the
@@ -795,6 +805,9 @@ func Paste() tea.Msg {
} }
func clamp(v, low, high int) int { func clamp(v, low, high int) int {
if high < low {
low, high = high, low
}
return min(high, max(low, v)) return min(high, max(low, v))
} }

View File

@@ -358,7 +358,10 @@ func (m Model) View() string {
extraLines = strings.Repeat("\n", max(0, m.Height-len(lines))) extraLines = strings.Repeat("\n", max(0, m.Height-len(lines)))
} }
return m.Style.Render(strings.Join(lines, "\n") + extraLines) return m.Style.Copy().
UnsetWidth().
UnsetHeight().
Render(strings.Join(lines, "\n") + extraLines)
} }
func clamp(v, low, high int) int { func clamp(v, low, high int) int {