mirror of
https://github.com/Maks1mS/bubbles.git
synced 2025-10-19 00:49:54 +03:00
Compare commits
29 Commits
v0.10.0
...
progress-a
Author | SHA1 | Date | |
---|---|---|---|
|
6d6149cea8 | ||
|
00d61decf4 | ||
|
430b7b5d36 | ||
|
00ec90b59f | ||
|
aa0744fd8d | ||
|
cf1fe5f9ce | ||
|
6c18900279 | ||
|
d897463138 | ||
|
88562515cf | ||
|
e349920524 | ||
|
64b9e0582f | ||
|
057f7b9a4d | ||
|
1d489252fe | ||
|
06358c35f9 | ||
|
005233b529 | ||
|
18d25458da | ||
|
db97ac515d | ||
|
200f95759b | ||
|
7ecce3fb97 | ||
|
746834a7ce | ||
|
fd306528f9 | ||
|
a4dc540f3d | ||
|
151d1026dd | ||
|
daba232df4 | ||
|
7a6d306889 | ||
|
6c015a2aa8 | ||
|
764fd321f6 | ||
|
091c915462 | ||
|
505a16d057 |
12
.github/workflows/soft-serve.yml
vendored
Normal file
12
.github/workflows/soft-serve.yml
vendored
Normal 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 }}"
|
23
README.md
23
README.md
@@ -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 you’ve built a Bubble you think should be listed here,
|
If you’ve built a Bubble you think should be listed here,
|
||||||
[let us know](mailto:vt100@charm.sh).
|
[let us know](mailto:vt100@charm.sh).
|
||||||
|
4
go.mod
4
go.mod
@@ -3,10 +3,10 @@ module github.com/charmbracelet/bubbles
|
|||||||
go 1.13
|
go 1.13
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.2
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/charmbracelet/bubbletea v0.19.3
|
github.com/charmbracelet/bubbletea v0.19.3
|
||||||
github.com/charmbracelet/harmonica v0.1.0
|
github.com/charmbracelet/harmonica v0.1.0
|
||||||
github.com/charmbracelet/lipgloss v0.3.0
|
github.com/charmbracelet/lipgloss v0.4.0
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0
|
github.com/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/mattn/go-runewidth v0.0.13
|
github.com/mattn/go-runewidth v0.0.13
|
||||||
|
10
go.sum
10
go.sum
@@ -1,18 +1,17 @@
|
|||||||
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw=
|
github.com/charmbracelet/bubbletea v0.19.3 h1:OKeO/Y13rQQqt4snX+lePB0QrnW80UdrMNolnCcmoAw=
|
||||||
github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
|
github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
|
||||||
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
|
github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0=
|
||||||
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v0.3.0 h1:5MysOD6sHr4RP4jkZNWGVIul5GKoOsP12NgbgXPvAlA=
|
github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
|
||||||
github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk=
|
github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
|
||||||
github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
|
github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE=
|
||||||
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
|
github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
|
||||||
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
|
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
|
||||||
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
@@ -24,7 +23,6 @@ github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBc
|
|||||||
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
|
|
||||||
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
|
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
|
||||||
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
|
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
@@ -165,7 +165,12 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
out []string
|
// Linter note: at this time we don't think it's worth the additional
|
||||||
|
// code complexity involved in preallocating this slice.
|
||||||
|
//
|
||||||
|
//nolint:prealloc
|
||||||
|
out []string
|
||||||
|
|
||||||
totalWidth int
|
totalWidth int
|
||||||
sep = m.Styles.FullSeparator.Render(m.FullSeparator)
|
sep = m.Styles.FullSeparator.Render(m.FullSeparator)
|
||||||
sepWidth = lipgloss.Width(sep)
|
sepWidth = lipgloss.Width(sep)
|
||||||
|
@@ -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"),
|
||||||
// ),
|
// ),
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
|
57
list/list.go
57
list/list.go
@@ -67,15 +67,37 @@ func (f filteredItems) items() []Item {
|
|||||||
return agg
|
return agg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f filteredItems) matches() [][]int {
|
// FilterMatchesMsg contains data about items matched during filtering. The
|
||||||
agg := make([][]int, len(f))
|
// message should be routed to Update for processing.
|
||||||
for i, v := range f {
|
type FilterMatchesMsg []filteredItem
|
||||||
agg[i] = v.matches
|
|
||||||
}
|
// FilterFunc takes a term and a list of strings to search through
|
||||||
return agg
|
// (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
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterMatchesMsg []filteredItem
|
// 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{}
|
||||||
|
|
||||||
@@ -113,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
|
||||||
@@ -177,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,
|
||||||
@@ -330,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)
|
||||||
@@ -526,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)
|
||||||
}
|
}
|
||||||
@@ -608,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)
|
||||||
|
|
||||||
@@ -628,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)
|
||||||
@@ -1084,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 {
|
||||||
@@ -1135,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,
|
||||||
|
@@ -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
|
||||||
@@ -98,6 +99,11 @@ func WithWidth(w int) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSpringOptions sets the initial frequency and damping options for the
|
||||||
|
// progressbar's built-in spring-based animation. Frequency corresponds to
|
||||||
|
// speed, and damping to bounciness. For details see:
|
||||||
|
//
|
||||||
|
// https://github.com/charmbracelet/harmonica
|
||||||
func WithSpringOptions(frequency, damping float64) Option {
|
func WithSpringOptions(frequency, damping float64) Option {
|
||||||
return func(m *Model) {
|
return func(m *Model) {
|
||||||
m.SetSpringOptions(frequency, damping)
|
m.SetSpringOptions(frequency, damping)
|
||||||
@@ -105,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
|
||||||
@@ -136,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
|
||||||
@@ -198,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:
|
||||||
@@ -213,12 +231,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// SetSpringOptions sets the frequency and damping for the current spring.
|
// SetSpringOptions sets the frequency and damping for the current spring.
|
||||||
// Frequency corresponds to speed, and damping to bounciness. For details see:
|
// Frequency corresponds to speed, and damping to bounciness. For details see:
|
||||||
// https://github.com/charmbracelet/harmonica.
|
//
|
||||||
|
// https://github.com/charmbracelet/harmonica
|
||||||
func (m *Model) SetSpringOptions(frequency, damping float64) {
|
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.
|
||||||
@@ -231,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()
|
||||||
}
|
}
|
||||||
@@ -252,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.
|
||||||
@@ -345,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)
|
||||||
|
}
|
||||||
|
@@ -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
|
||||||
|
@@ -62,7 +62,7 @@ const (
|
|||||||
// seen for password fields on the command line.
|
// seen for password fields on the command line.
|
||||||
EchoNone
|
EchoNone
|
||||||
|
|
||||||
// EchoOnEdit
|
// EchoOnEdit.
|
||||||
)
|
)
|
||||||
|
|
||||||
// blinkCtx manages cursor blinking.
|
// blinkCtx manages cursor blinking.
|
||||||
@@ -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) {
|
||||||
@@ -234,7 +239,7 @@ func (m *Model) cursorStart() bool {
|
|||||||
return m.setCursor(0)
|
return m.setCursor(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CursorEnd moves the cursor to the end of the input field
|
// CursorEnd moves the cursor to the end of the input field.
|
||||||
func (m *Model) CursorEnd() {
|
func (m *Model) CursorEnd() {
|
||||||
m.cursorEnd()
|
m.cursorEnd()
|
||||||
}
|
}
|
||||||
@@ -412,7 +417,11 @@ func (m *Model) deleteWordLeft() bool {
|
|||||||
return m.deleteBeforeCursor()
|
return m.deleteBeforeCursor()
|
||||||
}
|
}
|
||||||
|
|
||||||
i := m.pos
|
// 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 {
|
||||||
@@ -434,10 +443,10 @@ func (m *Model) deleteWordLeft() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if i > 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[i:]...)
|
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return blink
|
return blink
|
||||||
@@ -455,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
|
||||||
@@ -475,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
|
||||||
@@ -796,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -76,13 +76,13 @@ func (m Model) AtTop() bool {
|
|||||||
// AtBottom returns whether or not the viewport is at or past the very bottom
|
// AtBottom returns whether or not the viewport is at or past the very bottom
|
||||||
// position.
|
// position.
|
||||||
func (m Model) AtBottom() bool {
|
func (m Model) AtBottom() bool {
|
||||||
return m.YOffset >= len(m.lines)-m.Height
|
return m.YOffset >= m.maxYOffset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PastBottom returns whether or not the viewport is scrolled beyond the last
|
// PastBottom returns whether or not the viewport is scrolled beyond the last
|
||||||
// line. This can happen when adjusting the viewport height.
|
// line. This can happen when adjusting the viewport height.
|
||||||
func (m Model) PastBottom() bool {
|
func (m Model) PastBottom() bool {
|
||||||
return m.YOffset > len(m.lines)-m.Height
|
return m.YOffset > m.maxYOffset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
|
// ScrollPercent returns the amount scrolled as a float between 0 and 1.
|
||||||
@@ -108,6 +108,12 @@ func (m *Model) SetContent(s string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxYOffset returns the maximum possible value of the y-offset based on the
|
||||||
|
// viewport's content and set height.
|
||||||
|
func (m Model) maxYOffset() int {
|
||||||
|
return max(0, len(m.lines)-m.Height)
|
||||||
|
}
|
||||||
|
|
||||||
// visibleLines returns the lines that should currently be visible in the
|
// visibleLines returns the lines that should currently be visible in the
|
||||||
// viewport.
|
// viewport.
|
||||||
func (m Model) visibleLines() (lines []string) {
|
func (m Model) visibleLines() (lines []string) {
|
||||||
@@ -131,7 +137,7 @@ func (m Model) scrollArea() (top, bottom int) {
|
|||||||
|
|
||||||
// SetYOffset sets the Y offset.
|
// SetYOffset sets the Y offset.
|
||||||
func (m *Model) SetYOffset(n int) {
|
func (m *Model) SetYOffset(n int) {
|
||||||
m.YOffset = clamp(n, 0, len(m.lines)-m.Height)
|
m.YOffset = clamp(n, 0, m.maxYOffset())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -213,12 +219,10 @@ func (m *Model) GotoTop() (lines []string) {
|
|||||||
|
|
||||||
// GotoBottom sets the viewport to the bottom position.
|
// GotoBottom sets the viewport to the bottom position.
|
||||||
func (m *Model) GotoBottom() (lines []string) {
|
func (m *Model) GotoBottom() (lines []string) {
|
||||||
m.SetYOffset(len(m.lines) - 1 - m.Height)
|
m.SetYOffset(m.maxYOffset())
|
||||||
return m.visibleLines()
|
return m.visibleLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
// COMMANDS
|
|
||||||
|
|
||||||
// Sync tells the renderer where the viewport will be located and requests
|
// 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
|
// a render of the current state of the viewport. It should be called for the
|
||||||
// first render and after a window resize.
|
// first render and after a window resize.
|
||||||
@@ -354,12 +358,16 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ETC
|
|
||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user