mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
@@ -8,7 +8,7 @@ The idea is simple: instead of every TUI app managing its own colors, they all s
|
|||||||
|
|
||||||
- `github.com/anotherhadi/ilovetui/style` — the theme itself: colors, pre-built panel styles, config loading.
|
- `github.com/anotherhadi/ilovetui/style` — the theme itself: colors, pre-built panel styles, config loading.
|
||||||
- `github.com/anotherhadi/ilovetui/bubbles` — themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`).
|
- `github.com/anotherhadi/ilovetui/bubbles` — themed constructors for official `bubbles/v2` components (`help`, `textarea`, `textinput`, `list`, `table`, `filepicker`, `spinner`, `progress`, `paginator`, `viewport`).
|
||||||
- `github.com/anotherhadi/ilovetui/tabs`, `.../layout`, `.../modal`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme. See each package's own README: [`tabs`](tabs/README.md), [`layout`](layout/README.md), [`modal`](modal/README.md), [`notification`](notification/README.md).
|
- `github.com/anotherhadi/ilovetui/tabs`, `.../helpbar`, `.../modal`, `.../drawer`, `.../notification` — custom components not found in the official `bubbles` library, styled from the same theme.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -127,8 +127,9 @@ t := tabs.New([]tabs.Item{{Title: "First", Model: firstPane}, {Title: "Second",
|
|||||||
`tabs` renders a horizontal tab bar styled from `style.S`. The host application renders the content
|
`tabs` renders a horizontal tab bar styled from `style.S`. The host application renders the content
|
||||||
below it; see [`tabs/README.md`](tabs/README.md) and `examples/tabs` for a full example.
|
below it; see [`tabs/README.md`](tabs/README.md) and `examples/tabs` for a full example.
|
||||||
|
|
||||||
For the other custom components, see their own README: [`layout`](layout/README.md) (BSP pane
|
For the other custom components, see their own README: [`helpbar`](helpbar/README.md) (responsive
|
||||||
layout with spatial focus navigation), [`modal`](modal/README.md) (centered popup dialogs) and
|
help bar that reflows into as many columns as fit), [`modal`](modal/README.md) (centered popup
|
||||||
|
dialogs), [`drawer`](drawer/README.md) (left/right sidebar panels, mirroring `modal`) and
|
||||||
[`notification`](notification/README.md) (toast notifications).
|
[`notification`](notification/README.md) (toast notifications).
|
||||||
|
|
||||||
## Projects using ilovetui
|
## Projects using ilovetui
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# drawer
|
||||||
|
|
||||||
|
A full-height panel flush against the left or right edge of an already-rendered background, on top
|
||||||
|
of it dimmed - the sidebar/drawer equivalent of [`modal`](../modal/README.md), which this package
|
||||||
|
otherwise mirrors closely: same stack of panels triggered from anywhere via an exported `tea.Msg`
|
||||||
|
(`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering it, same
|
||||||
|
composite-over-an-already-rendered-string `Render`, no assumption about how the host builds that
|
||||||
|
string.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"github.com/anotherhadi/ilovetui/drawer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
d drawer.Model
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModel() model {
|
||||||
|
return model{d: drawer.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd { return m.d.Init() }
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
if msg.String() == "l" {
|
||||||
|
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
|
||||||
|
}
|
||||||
|
if msg.String() == "esc" && m.d.Open() {
|
||||||
|
return m, drawer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.d, cmd = m.d.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() tea.View {
|
||||||
|
background := renderYourUI(m.width, m.height)
|
||||||
|
view := tea.NewView(m.d.Render(background))
|
||||||
|
view.AltScreen = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Any component in the same bubbletea program can trigger a drawer via `drawer.Show`, without
|
||||||
|
holding a reference to the `drawer.Model` that will actually render it - that `Model` just needs
|
||||||
|
to see every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level
|
||||||
|
`Update`), same as any other child model.
|
||||||
|
|
||||||
|
## Content is a model
|
||||||
|
|
||||||
|
A drawer's body is a `tea.Model`, not a string. While a drawer is on top of the stack it gets
|
||||||
|
every message the `drawer.Model` receives, its `Init` runs when it opens, and its commands come
|
||||||
|
back out - so it can hold a file list, a filter form, or a picker that reports its choice with a
|
||||||
|
`tea.Msg` of its own, which the component that opened it listens for:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type pickedMsg struct{ file string }
|
||||||
|
|
||||||
|
// somewhere else
|
||||||
|
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the topmost drawer is updated: everything beneath it is dimmed and frozen until the drawers
|
||||||
|
above it close.
|
||||||
|
|
||||||
|
For a drawer with nothing to interact with, `drawer.Text` wraps a plain string:
|
||||||
|
|
||||||
|
```go
|
||||||
|
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
|
||||||
|
```
|
||||||
|
|
||||||
|
The box shrinks to fit whatever the content draws (unless `WithWidth` fixes it), so a content
|
||||||
|
that wants a specific size sets it on itself - the drawer only ever sees the rendered result.
|
||||||
|
|
||||||
|
## Side and width
|
||||||
|
|
||||||
|
```go
|
||||||
|
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
|
||||||
|
drawer.WithSide(drawer.Right), drawer.WithWidth(24))
|
||||||
|
```
|
||||||
|
|
||||||
|
`WithSide` anchors the drawer to `drawer.Left` (the default) or `drawer.Right`. `WithWidth` fixes
|
||||||
|
the drawer's total width instead of shrinking to fit its content, still capped by whatever
|
||||||
|
actually fits the background - same unit as the `Model`-level `WithMaxWidth`. Either way, the
|
||||||
|
drawer always spans the background's full height, flush top to bottom.
|
||||||
|
|
||||||
|
## Showing and closing
|
||||||
|
|
||||||
|
```go
|
||||||
|
return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
|
||||||
|
|
||||||
|
return m, drawer.Close() // close the topmost drawer
|
||||||
|
```
|
||||||
|
|
||||||
|
The stack is a plain LIFO, with no identity: a drawer is closed by being on top, never by being
|
||||||
|
named. There is nothing to tag a drawer with, and nothing that can target one in the middle of the
|
||||||
|
stack - the topmost is both the only one that receives messages and the only one `Close` can
|
||||||
|
reach, so the two rules never disagree.
|
||||||
|
|
||||||
|
- `drawer.Open()` reports whether at least one drawer is currently shown - handy for a host that
|
||||||
|
wants to route key presses to the drawer instead of its normal UI while one is open. Note that a
|
||||||
|
host doing this also makes it impossible for a key to open a second drawer, which is what keeps
|
||||||
|
the stack shallow without any bookkeeping.
|
||||||
|
- A content model closes its own drawer by returning `drawer.Close()`, since it only ever runs
|
||||||
|
while it is the topmost one.
|
||||||
|
|
||||||
|
## Stacking
|
||||||
|
|
||||||
|
Drawers stack: showing a second one while the first is still open pushes it on top, dimming both
|
||||||
|
the background and the first drawer to the same flat color, same as `modal`. Opening a left drawer
|
||||||
|
and a right drawer at once still stacks - if you want both visible at full color simultaneously,
|
||||||
|
render them as two ordinary panels via `layout` instead; this package is for transient
|
||||||
|
sidebars, not permanent chrome.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
```go
|
||||||
|
d := drawer.New(drawer.WithMaxWidth(30), drawer.WithStyles(myStyles))
|
||||||
|
|
||||||
|
return m, drawer.Show("Nav", drawer.Text("content"), drawer.WithDrawerStyle(oneOffStyles))
|
||||||
|
```
|
||||||
|
|
||||||
|
`WithMaxWidth` caps how wide a drawer can grow (border and padding included) before wrapping; a
|
||||||
|
drawer narrower than the cap shrinks to fit its content instead of padding out to it, unless shown
|
||||||
|
with `WithWidth`. A drawer can also never overflow past the edge of whatever background it's
|
||||||
|
rendered on. `WithStyles` sets the default look for every drawer shown by this `Model`;
|
||||||
|
`WithDrawerStyle` (a `Show` option) overrides it for one drawer alone. `DefaultStyles()` builds
|
||||||
|
from `style.S`, same palette roles as `modal.DefaultStyles()`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- `examples/drawer` - a left nav drawer and a right inspector drawer, either one at a time.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package drawer
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// Side is which edge of the background a Drawer is anchored to.
|
||||||
|
type Side int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Left anchors the drawer to the left edge (the zero value, so a Drawer
|
||||||
|
// built without WithSide opens on the left).
|
||||||
|
Left Side = iota
|
||||||
|
// Right anchors the drawer to the right edge.
|
||||||
|
Right
|
||||||
|
)
|
||||||
|
|
||||||
|
// Drawer is one sidebar panel on the stack.
|
||||||
|
type Drawer struct {
|
||||||
|
Title string
|
||||||
|
// Content is the drawer's body: a full model, updated and rendered by
|
||||||
|
// the drawer.Model while it's on top of the stack. Wrap a plain string
|
||||||
|
// with Text for a drawer with nothing to interact with.
|
||||||
|
Content tea.Model
|
||||||
|
Side Side
|
||||||
|
// Width, if non-zero, fixes the drawer's total width (border and
|
||||||
|
// padding included, same unit as the Model's WithMaxWidth) instead of
|
||||||
|
// shrinking to fit what Content draws, and Title. Still capped by whatever actually
|
||||||
|
// fits the background.
|
||||||
|
Width int
|
||||||
|
// Style, if non-nil, overrides the Model's default Styles for this
|
||||||
|
// drawer alone.
|
||||||
|
Style *Styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// DrawerOption configures a Drawer built by Show.
|
||||||
|
type DrawerOption func(*Drawer)
|
||||||
|
|
||||||
|
// WithSide anchors the drawer to the given Side (default Left).
|
||||||
|
func WithSide(s Side) DrawerOption {
|
||||||
|
return func(d *Drawer) { d.Side = s }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithWidth fixes the drawer's total width instead of shrinking to fit its
|
||||||
|
// content, still capped by whatever actually fits the background.
|
||||||
|
func WithWidth(w int) DrawerOption {
|
||||||
|
return func(d *Drawer) { d.Width = w }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDrawerStyle overrides the Model's default Styles for this drawer
|
||||||
|
// alone, for a one-off custom look instead of the shared theme.
|
||||||
|
func WithDrawerStyle(s Styles) DrawerOption {
|
||||||
|
return func(d *Drawer) { d.Style = &s }
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDrawer(title string, content tea.Model, opts ...DrawerOption) Drawer {
|
||||||
|
d := Drawer{Title: title, Content: content}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&d)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShowMsg tells a drawer.Model to display Drawer, pushing it on top of the
|
||||||
|
// stack. Any component in the same bubbletea program can trigger one via
|
||||||
|
// Show, without holding a reference to the drawer.Model that will actually
|
||||||
|
// render it - that Model just needs to see every tea.Msg the program
|
||||||
|
// produces, same as any other child model.
|
||||||
|
type ShowMsg struct{ Drawer Drawer }
|
||||||
|
|
||||||
|
// Show returns a tea.Cmd that opens a new drawer on top of the stack,
|
||||||
|
// anchored to the left edge by default. content is a model, so a drawer can
|
||||||
|
// hold anything a pane can - a file list, a filter form, a picker that
|
||||||
|
// reports its choice with its own tea.Msg:
|
||||||
|
//
|
||||||
|
// return m, drawer.Show("Files", newFileList(dir), drawer.WithSide(drawer.Right))
|
||||||
|
// return m, drawer.Show("Nav", drawer.Text("Home\nProjects"))
|
||||||
|
//
|
||||||
|
// The content's Init runs when the drawer opens, and it receives every
|
||||||
|
// message while it's the topmost drawer (see Model.Update).
|
||||||
|
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
|
||||||
|
d := newDrawer(title, content, opts...)
|
||||||
|
return func() tea.Msg { return ShowMsg{Drawer: d} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// DismissMsg closes the topmost drawer. The stack is a plain LIFO: a drawer
|
||||||
|
// is closed by being on top, never by being named.
|
||||||
|
type DismissMsg struct{}
|
||||||
|
|
||||||
|
// Close returns a tea.Cmd that closes the topmost drawer - which is also the
|
||||||
|
// only one that can act (see Model.Update), so a content model closes itself
|
||||||
|
// by returning it:
|
||||||
|
//
|
||||||
|
// return c, drawer.Close()
|
||||||
|
func Close() tea.Cmd {
|
||||||
|
return func() tea.Msg { return DismissMsg{} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// text is a model wrapping a fixed string: a drawer body with nothing to
|
||||||
|
// update.
|
||||||
|
type text string
|
||||||
|
|
||||||
|
func (t text) Init() tea.Cmd { return nil }
|
||||||
|
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
|
||||||
|
func (t text) View() tea.View { return tea.NewView(string(t)) }
|
||||||
|
|
||||||
|
// Text wraps a plain string as drawer content, for the common drawer that has
|
||||||
|
// nothing to interact with:
|
||||||
|
//
|
||||||
|
// drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"))
|
||||||
|
func Text(s string) tea.Model { return text(s) }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Package drawer renders a full-height panel flush against the left or
|
||||||
|
// right edge of an already-rendered background, on top of it dimmed -
|
||||||
|
// the sidebar/drawer equivalent of github.com/anotherhadi/ilovetui/modal,
|
||||||
|
// which this package otherwise mirrors closely: same stack of panels
|
||||||
|
// triggered from anywhere via an exported tea.Msg (see ShowMsg/Show)
|
||||||
|
// rather than a direct reference to the Model that ends up rendering it,
|
||||||
|
// same composite-over-an-already-rendered-string Render, same absence of
|
||||||
|
// any assumption about how the host builds that string.
|
||||||
|
//
|
||||||
|
// A drawer's content is a model, not a string: it is updated while it's on
|
||||||
|
// top of the stack, so it can hold anything a pane can - a file list, a
|
||||||
|
// filter form, a picker reporting its choice back with its own tea.Msg. See
|
||||||
|
// Show and Text.
|
||||||
|
package drawer
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// Model holds the currently open drawers (a stack: the most recently shown
|
||||||
|
// is drawn on top, everything beneath it - the background and any earlier
|
||||||
|
// drawer - dimmed, see Render) and the rendering config (max width, styles)
|
||||||
|
// they share. Build one with New.
|
||||||
|
type Model struct {
|
||||||
|
drawers []Drawer
|
||||||
|
nextID int
|
||||||
|
maxWidth int
|
||||||
|
styles Styles
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Model at construction. See WithMaxWidth, WithStyles.
|
||||||
|
type Option func(*Model)
|
||||||
|
|
||||||
|
// WithMaxWidth caps a drawer's total width (border and padding included). A
|
||||||
|
// drawer shown without WithWidth shrinks to fit its content instead of
|
||||||
|
// padding out to the cap; a drawer shown with WithWidth uses that width
|
||||||
|
// instead, still capped by this. 0 means only the background's own width
|
||||||
|
// caps it.
|
||||||
|
func WithMaxWidth(w int) Option {
|
||||||
|
return func(m *Model) { m.maxWidth = w }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStyles overrides the default styles (see DefaultStyles).
|
||||||
|
func WithStyles(s Styles) Option {
|
||||||
|
return func(m *Model) { m.styles = s }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a Model. Defaults: a 30-column max width, DefaultStyles.
|
||||||
|
func New(opts ...Option) Model {
|
||||||
|
m := Model{
|
||||||
|
maxWidth: 30,
|
||||||
|
styles: DefaultStyles(),
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case ShowMsg:
|
||||||
|
return m.show(msg.Drawer)
|
||||||
|
case DismissMsg:
|
||||||
|
return m.pop(), nil
|
||||||
|
}
|
||||||
|
return m.updateTop(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTop forwards msg to the topmost drawer's content - the only one the
|
||||||
|
// user can interact with, everything beneath it being dimmed (see Render). A
|
||||||
|
// drawer deeper in the stack is frozen until the ones above it close.
|
||||||
|
//
|
||||||
|
// This is what lets drawer content be a real model: it gets the key presses,
|
||||||
|
// the ticks and the results of its own commands, and can report back to the
|
||||||
|
// rest of the program with a tea.Msg of its own.
|
||||||
|
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
i := len(m.drawers) - 1
|
||||||
|
if i < 0 || m.drawers[i].Content == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.drawers[i].Content, cmd = m.drawers[i].Content.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open reports whether at least one drawer is currently shown - handy for a
|
||||||
|
// host that wants to route key presses to the drawer (e.g. esc to dismiss)
|
||||||
|
// instead of its normal UI while one is open.
|
||||||
|
func (m Model) Open() bool { return len(m.drawers) > 0 }
|
||||||
|
|
||||||
|
// show pushes a drawer on top of the stack and returns its content's Init - a
|
||||||
|
// drawer's body starts the same way any other model does.
|
||||||
|
func (m Model) show(d Drawer) (Model, tea.Cmd) {
|
||||||
|
m.drawers = append(m.drawers, d)
|
||||||
|
return m, initContent(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initContent is d's content's Init, or nil for a drawer without content.
|
||||||
|
func initContent(d Drawer) tea.Cmd {
|
||||||
|
if d.Content == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return d.Content.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop closes the topmost drawer (see Close).
|
||||||
|
func (m Model) pop() Model {
|
||||||
|
if len(m.drawers) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
m.drawers = m.drawers[:len(m.drawers)-1]
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package drawer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render draws every open drawer (see Model.Update/Show) on top of
|
||||||
|
// background (already rendered, e.g. layout.Model.View() or any other
|
||||||
|
// component's View()) and returns the result. Each drawer in the stack
|
||||||
|
// first flattens whatever came before it - background plus any earlier
|
||||||
|
// drawer - to a single flat DimColor (see dim), then draws its own
|
||||||
|
// full-height box flush against its Side on top, so nesting a second
|
||||||
|
// drawer on top of a first dims the first one too. background is returned
|
||||||
|
// unchanged whenever there's nothing to draw.
|
||||||
|
func (m Model) Render(background string) string {
|
||||||
|
if len(m.drawers) == 0 {
|
||||||
|
return background
|
||||||
|
}
|
||||||
|
|
||||||
|
result := background
|
||||||
|
for _, d := range m.drawers {
|
||||||
|
w, h := lipgloss.Width(result), lipgloss.Height(result)
|
||||||
|
if w <= 0 || h <= 0 {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result = m.renderOne(d, result, w, h)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// View is a convenience for a pane whose sole purpose is showing drawers
|
||||||
|
// (e.g. a dedicated layout.Leaf): it draws the stack over a blank
|
||||||
|
// width x height area instead of an existing background.
|
||||||
|
func (m Model) View(width, height int) string {
|
||||||
|
return m.Render(blank(width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderOne dims background flat and draws d's box, spanning its full
|
||||||
|
// height, flush against d.Side.
|
||||||
|
func (m Model) renderOne(d Drawer, background string, w, h int) string {
|
||||||
|
s := m.styles
|
||||||
|
if d.Style != nil {
|
||||||
|
s = *d.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
box := m.renderBox(d, s, w, h)
|
||||||
|
bw := lipgloss.Width(box)
|
||||||
|
x := 0
|
||||||
|
if d.Side == Right {
|
||||||
|
x = max(w-bw, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
compositor := lipgloss.NewCompositor(
|
||||||
|
lipgloss.NewLayer(dim(background, s.DimColor)),
|
||||||
|
lipgloss.NewLayer(box).X(x).Y(0).Z(1),
|
||||||
|
)
|
||||||
|
return compositor.Render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dim flattens s to a single flat color: every existing style (colors,
|
||||||
|
// bold, underline...) is stripped, then every character - including
|
||||||
|
// whitespace, so highlighted/selected backgrounds vanish too - is
|
||||||
|
// repainted in c. Applying a Foreground style to a multi-line string styles
|
||||||
|
// each line independently (see lipgloss.Style.Render), so this keeps s's
|
||||||
|
// line structure intact.
|
||||||
|
func dim(s string, c color.Color) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderBox draws d as a bordered, title-embedded box (style.RenderWithTitle)
|
||||||
|
// spanning the background's full height, capped by the Model's configured
|
||||||
|
// max width and by whatever actually fits inside a bgW-wide background.
|
||||||
|
func (m Model) renderBox(d Drawer, s Styles, bgW, bgH int) string {
|
||||||
|
widthCap := m.maxWidth
|
||||||
|
if d.Width > 0 {
|
||||||
|
widthCap = d.Width
|
||||||
|
}
|
||||||
|
maxW := effectiveMax(widthCap, bgW)
|
||||||
|
|
||||||
|
body := contentView(d)
|
||||||
|
inner := contentWidth(d, body, maxW)
|
||||||
|
content := s.Content.Width(inner).Render(body)
|
||||||
|
|
||||||
|
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
|
||||||
|
|
||||||
|
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentView is the drawer body's rendered string, or "" for a drawer
|
||||||
|
// without content. The box shrinks to fit whatever the content model draws,
|
||||||
|
// so a content that wants a specific size sets it on itself - the drawer only
|
||||||
|
// ever sees the result.
|
||||||
|
func contentView(d Drawer) string {
|
||||||
|
if d.Content == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Content.View().Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentWidth is the drawer's inner (border/padding excluded) width, given
|
||||||
|
// the resolved total-width cap maxWidth (see renderBox): maxWidth-4 if
|
||||||
|
// d.Width is set (a fixed total width), otherwise its natural size (long
|
||||||
|
// enough for the widest line of title/content), capped at maxWidth-4.
|
||||||
|
func contentWidth(d Drawer, body string, maxWidth int) int {
|
||||||
|
capped := max(maxWidth-4, 1)
|
||||||
|
if d.Width > 0 {
|
||||||
|
return capped
|
||||||
|
}
|
||||||
|
natural := max(naturalWidth(body), lipgloss.Width(d.Title), 1)
|
||||||
|
return min(natural, capped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// naturalWidth is the width of content's widest line.
|
||||||
|
func naturalWidth(content string) int {
|
||||||
|
w := 0
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if lw := lipgloss.Width(line); lw > w {
|
||||||
|
w = lw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveMax resolves the cap actually used along the width axis:
|
||||||
|
// configured (0 = unlimited) narrowed down to fits, whatever actually fits
|
||||||
|
// the background - a drawer can never overflow past the edge of the
|
||||||
|
// background, or the terminal, when the background is a full-screen View(),
|
||||||
|
// regardless of how WithMaxWidth/WithWidth was set.
|
||||||
|
func effectiveMax(configured, fits int) int {
|
||||||
|
if fits < 1 {
|
||||||
|
fits = 1
|
||||||
|
}
|
||||||
|
if configured > 0 && configured < fits {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
return fits
|
||||||
|
}
|
||||||
|
|
||||||
|
func blank(width, height int) string {
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
line := strings.Repeat(" ", width)
|
||||||
|
lines := make([]string, height)
|
||||||
|
for i := range lines {
|
||||||
|
lines[i] = line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package drawer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func background(w, h int) string {
|
||||||
|
return blank(w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderLeftFlushToLeftEdge(t *testing.T) {
|
||||||
|
m := New(WithMaxWidth(10))
|
||||||
|
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"), WithSide(Left))})
|
||||||
|
|
||||||
|
out := m.Render(background(40, 10))
|
||||||
|
lines := strings.Split(out, "\n")
|
||||||
|
if len(lines) != 10 {
|
||||||
|
t.Fatalf("expected 10 lines, got %d", len(lines))
|
||||||
|
}
|
||||||
|
if lipgloss.Width(out) == 0 {
|
||||||
|
t.Fatalf("expected non-empty render")
|
||||||
|
}
|
||||||
|
// The border's top-left corner glyph should be the very first rune.
|
||||||
|
if len([]rune(lines[0])) == 0 {
|
||||||
|
t.Fatalf("expected a rendered top border line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderRightFlushToRightEdge(t *testing.T) {
|
||||||
|
m := New(WithMaxWidth(10))
|
||||||
|
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Inspector", Text("hi"), WithSide(Right))})
|
||||||
|
|
||||||
|
out := m.Render(background(40, 10))
|
||||||
|
if lipgloss.Width(out) != 40 {
|
||||||
|
t.Fatalf("expected full background width 40, got %d", lipgloss.Width(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpansFullBackgroundHeight(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("hi"))})
|
||||||
|
|
||||||
|
out := m.Render(background(40, 12))
|
||||||
|
if lipgloss.Height(out) != 12 {
|
||||||
|
t.Fatalf("expected full height 12, got %d", lipgloss.Height(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFixedWidthHonored(t *testing.T) {
|
||||||
|
m := New(WithMaxWidth(50))
|
||||||
|
m, _ = m.Update(ShowMsg{Drawer: newDrawer("Nav", Text("x"), WithWidth(20))})
|
||||||
|
|
||||||
|
box := m.renderBox(m.drawers[0], m.styles, 80, 10)
|
||||||
|
if got := lipgloss.Width(box); got != 20 { // WithWidth is the total box width
|
||||||
|
t.Fatalf("expected fixed box width 20, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoDrawersReturnsBackgroundUnchanged(t *testing.T) {
|
||||||
|
m := New()
|
||||||
|
bg := background(10, 5)
|
||||||
|
if got := m.Render(bg); got != bg {
|
||||||
|
t.Fatalf("expected background unchanged when no drawer is open")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package drawer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Styles is the set of lipgloss styles, plus the dim color, used to render a
|
||||||
|
// drawer and the background behind it. Border carries the box's border
|
||||||
|
// (shape + color, no size), Title and Content color the two pieces of text
|
||||||
|
// drawn inside it, DimColor is the single flat color every character of the
|
||||||
|
// background gets overwritten with while the drawer is open.
|
||||||
|
type Styles struct {
|
||||||
|
Border lipgloss.Style
|
||||||
|
Title lipgloss.Style
|
||||||
|
Content lipgloss.Style
|
||||||
|
DimColor color.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStyles builds a Styles from style.S: the box borrows
|
||||||
|
// PanelFocused's border (the drawer is what has focus while it's open).
|
||||||
|
// DimColor reuses Subtle - the base16 "comments/invisibles" role, already
|
||||||
|
// used across this repo for de-emphasized text (borders, placeholders,
|
||||||
|
// separators, see bubbles/*.go and modal.DefaultStyles) - darker than
|
||||||
|
// Muted, which reads too bright once it's covering an entire screen instead
|
||||||
|
// of a single blurred field.
|
||||||
|
func DefaultStyles() Styles {
|
||||||
|
return Styles{
|
||||||
|
Border: style.S.PanelFocused.Padding(0, 1),
|
||||||
|
Title: lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary),
|
||||||
|
Content: lipgloss.NewStyle().Foreground(style.S.Text),
|
||||||
|
DimColor: style.S.Subtle,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/drawer"
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
d drawer.Model
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModel() model {
|
||||||
|
return model{d: drawer.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd { return m.d.Init() }
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "q":
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case "l":
|
||||||
|
return m, drawer.Show("Nav", drawer.Text("Home\nProjects\nSettings"),
|
||||||
|
drawer.WithSide(drawer.Left), drawer.WithWidth(20))
|
||||||
|
|
||||||
|
case "r":
|
||||||
|
return m, drawer.Show("Inspector", drawer.Text("id: 42\nstatus: ok"),
|
||||||
|
drawer.WithSide(drawer.Right), drawer.WithWidth(20))
|
||||||
|
|
||||||
|
case "esc":
|
||||||
|
if m.d.Open() {
|
||||||
|
return m, drawer.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.d, cmd = m.d.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() tea.View {
|
||||||
|
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
|
||||||
|
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
|
||||||
|
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the drawer.")
|
||||||
|
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
|
||||||
|
"l: open left drawer r: open right drawer esc: close the top one q: quit")
|
||||||
|
|
||||||
|
background := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
|
||||||
|
lipgloss.JoinVertical(lipgloss.Center, title, "", body, "", help))
|
||||||
|
|
||||||
|
view := tea.NewView(m.d.Render(background))
|
||||||
|
view.AltScreen = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||||
|
fmt.Println("Error running program:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/drawer"
|
||||||
|
"github.com/anotherhadi/ilovetui/examples/fullapp/metrics"
|
||||||
|
"github.com/anotherhadi/ilovetui/examples/fullapp/overview"
|
||||||
|
"github.com/anotherhadi/ilovetui/examples/fullapp/settings"
|
||||||
|
"github.com/anotherhadi/ilovetui/examples/fullapp/sidebar"
|
||||||
|
"github.com/anotherhadi/ilovetui/helpbar"
|
||||||
|
"github.com/anotherhadi/ilovetui/modal"
|
||||||
|
"github.com/anotherhadi/ilovetui/notification"
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pages = []struct {
|
||||||
|
item sidebar.NavItem
|
||||||
|
new func() tea.Model
|
||||||
|
}{
|
||||||
|
{sidebar.NavItem{Icon: "", Name: "Overview"}, func() tea.Model { return overview.New() }},
|
||||||
|
{sidebar.NavItem{Icon: "", Name: "Metrics"}, func() tea.Model { return metrics.New() }},
|
||||||
|
{sidebar.NavItem{Icon: "", Name: "Settings"}, func() tea.Model { return settings.New() }},
|
||||||
|
}
|
||||||
|
|
||||||
|
type HelpProvider interface {
|
||||||
|
HelpBindings() []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func navItems() []sidebar.NavItem {
|
||||||
|
items := make([]sidebar.NavItem, len(pages))
|
||||||
|
for i, p := range pages {
|
||||||
|
items[i] = p.item
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyMap struct {
|
||||||
|
FocusSidebar key.Binding
|
||||||
|
Close key.Binding
|
||||||
|
Help key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultKeyMap() keyMap {
|
||||||
|
return keyMap{
|
||||||
|
FocusSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "focus sidebar")),
|
||||||
|
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
|
||||||
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
|
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q / ctrl+c", "quit")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
sidebar sidebar.Model
|
||||||
|
page tea.Model
|
||||||
|
help helpbar.Model
|
||||||
|
notif notification.Model
|
||||||
|
modal modal.Model
|
||||||
|
drawer drawer.Model
|
||||||
|
keys keyMap
|
||||||
|
|
||||||
|
sidebarWidth int
|
||||||
|
hideNav bool
|
||||||
|
contentFocused bool
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModel() Model {
|
||||||
|
keys := defaultKeyMap()
|
||||||
|
return Model{
|
||||||
|
sidebar: sidebar.New(navItems()...),
|
||||||
|
sidebarWidth: 24,
|
||||||
|
page: pages[0].new(),
|
||||||
|
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.FocusSidebar, keys.Quit)),
|
||||||
|
notif: notification.New(),
|
||||||
|
modal: modal.New(),
|
||||||
|
drawer: drawer.New(),
|
||||||
|
keys: keys,
|
||||||
|
hideNav: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return m.page.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
return m.resize()
|
||||||
|
|
||||||
|
case sidebar.SelectMsg:
|
||||||
|
m.page = pages[msg.Index].new()
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m, cmd = m.resize()
|
||||||
|
return m, tea.Batch(m.page.Init(), cmd)
|
||||||
|
|
||||||
|
case sidebar.BlurMsg:
|
||||||
|
m.contentFocused = true
|
||||||
|
return m.resize()
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
if m.modal.Open() {
|
||||||
|
if key.Matches(msg, m.keys.Close) {
|
||||||
|
return m, modal.Close()
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.modal, cmd = m.modal.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
if m.drawer.Open() {
|
||||||
|
if key.Matches(msg, m.keys.Close) {
|
||||||
|
return m, drawer.Close()
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.drawer, cmd = m.drawer.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Quit):
|
||||||
|
return m, tea.Quit
|
||||||
|
case key.Matches(msg, m.keys.Help):
|
||||||
|
m.help, _ = m.help.Update(msg)
|
||||||
|
return m.resize()
|
||||||
|
case key.Matches(msg, m.keys.FocusSidebar):
|
||||||
|
m.contentFocused = !m.contentFocused
|
||||||
|
return m.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.contentFocused {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.page, cmd = m.page.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.sidebar, cmd = m.sidebar.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
var notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd tea.Cmd
|
||||||
|
m.notif, notifCmd = m.notif.Update(msg)
|
||||||
|
m.modal, modalCmd = m.modal.Update(msg)
|
||||||
|
m.drawer, drawerCmd = m.drawer.Update(msg)
|
||||||
|
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
|
||||||
|
m.page, pageCmd = m.page.Update(msg)
|
||||||
|
return m, tea.Batch(notifCmd, modalCmd, drawerCmd, sidebarCmd, pageCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) navWidth() int {
|
||||||
|
if m.hideNav && m.contentFocused {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.sidebarWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) resize() (Model, tea.Cmd) {
|
||||||
|
if m.width <= 0 || m.height <= 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.help.SetWidth(m.width)
|
||||||
|
inner := style.ContentHeight(m.height - m.help.Height(m.focusedHelp()...))
|
||||||
|
|
||||||
|
navW := m.navWidth()
|
||||||
|
m.sidebar.SetSize(max(navW-2, 0), inner)
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.page, cmd = m.page.Update(tea.WindowSizeMsg{
|
||||||
|
Width: max(m.width-navW-2, 0),
|
||||||
|
Height: inner,
|
||||||
|
})
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) focusedHelp() []key.Binding {
|
||||||
|
if !m.contentFocused {
|
||||||
|
return m.sidebar.HelpBindings()
|
||||||
|
}
|
||||||
|
if hp, ok := m.page.(HelpProvider); ok {
|
||||||
|
return hp.HelpBindings()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
if m.width <= 0 || m.height <= 0 {
|
||||||
|
return tea.NewView("")
|
||||||
|
}
|
||||||
|
helpBar := m.help.View(m.focusedHelp()...)
|
||||||
|
bodyH := max(m.height-lipgloss.Height(helpBar), 0)
|
||||||
|
|
||||||
|
navW := m.navWidth()
|
||||||
|
body := style.RenderWithTitle(
|
||||||
|
panel(m.contentFocused), m.sidebar.Selected().Name, m.page.View().Content, m.width-navW, bodyH)
|
||||||
|
|
||||||
|
if navW > 0 {
|
||||||
|
left := style.RenderWithTitle(
|
||||||
|
panel(!m.contentFocused), "Menu", m.sidebar.View(), navW, bodyH)
|
||||||
|
body = lipgloss.JoinHorizontal(lipgloss.Top, left, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
appView := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
body,
|
||||||
|
helpBar,
|
||||||
|
)
|
||||||
|
|
||||||
|
view := tea.NewView(m.notif.Render(m.modal.Render(m.drawer.Render(appView))))
|
||||||
|
view.AltScreen = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func panel(focused bool) lipgloss.Style {
|
||||||
|
if focused {
|
||||||
|
return style.S.PanelFocused
|
||||||
|
}
|
||||||
|
return style.S.Panel
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if _, err := tea.NewProgram(NewModel()).Run(); err != nil {
|
||||||
|
fmt.Println("Error running program:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package metrics is the fullapp example's second page: a spinner and a few
|
||||||
|
// gauges. It has no keys either, but unlike overview it runs a command, so
|
||||||
|
// it's what proves the shell keeps feeding ticks to a pane that doesn't have
|
||||||
|
// focus.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/progress"
|
||||||
|
"charm.land/bubbles/v2/spinner"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/bubbles"
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gaugeWidth is how wide a bar renders, before the label in front of it.
|
||||||
|
const gaugeWidth = 30
|
||||||
|
|
||||||
|
type gauge struct {
|
||||||
|
name string
|
||||||
|
percent float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is the page. Values are hardcoded - this is a layout test, not a
|
||||||
|
// monitoring tool.
|
||||||
|
type Model struct {
|
||||||
|
spinner spinner.Model
|
||||||
|
bar progress.Model
|
||||||
|
gauges []gauge
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() Model {
|
||||||
|
bar := bubbles.NewProgress()
|
||||||
|
bar.SetWidth(gaugeWidth)
|
||||||
|
return Model{
|
||||||
|
spinner: bubbles.NewSpinner(spinner.WithSpinner(spinner.MiniDot)),
|
||||||
|
bar: bar,
|
||||||
|
gauges: []gauge{
|
||||||
|
{"cpu", 0.42},
|
||||||
|
{"memory", 0.71},
|
||||||
|
{"disk", 0.13},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init starts the spinner ticking. The shell returns it from its own Init,
|
||||||
|
// or the spinner never starts.
|
||||||
|
func (m Model) Init() tea.Cmd { return m.spinner.Tick }
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
|
m.width, m.height = size.Width, size.Height
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every other message goes to the spinner, whose own tick keeps it
|
||||||
|
// turning - including while another pane has focus.
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
rows := []string{style.S.Bold.Render(m.spinner.View() + " collecting")}
|
||||||
|
for _, g := range m.gauges {
|
||||||
|
// ViewAs renders a given percentage without animating toward it,
|
||||||
|
// which is all a fixed value needs.
|
||||||
|
rows = append(rows, fmt.Sprintf("%-8s %s", g.name, m.bar.ViewAs(g.percent)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return tea.NewView(lipgloss.NewStyle().
|
||||||
|
Width(m.width).Height(m.height).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
AlignVertical(lipgloss.Center).
|
||||||
|
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Package overview is the fullapp example's first page: the simplest pane
|
||||||
|
// there is - no keys, no commands, just text sized to whatever room the
|
||||||
|
// shell gives it.
|
||||||
|
package overview
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the page. It's an ordinary tea.Model: nothing about it knows it
|
||||||
|
// lives in a shell, and it never implements HelpBindings because it has no
|
||||||
|
// keys of its own to advertise.
|
||||||
|
type Model struct {
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() Model { return Model{} }
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
// Update only tracks the size the shell hands down as a tea.WindowSizeMsg,
|
||||||
|
// same as if the page were the whole program.
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||||||
|
m.width, m.height = size.Width, size.Height
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
body := lipgloss.JoinVertical(lipgloss.Center,
|
||||||
|
style.S.Bold.Render("Overview"),
|
||||||
|
"",
|
||||||
|
style.S.Faint.Render("tab focuses this pane, but there's"),
|
||||||
|
style.S.Faint.Render("nothing here to focus on."),
|
||||||
|
)
|
||||||
|
return tea.NewView(lipgloss.NewStyle().
|
||||||
|
Width(m.width).Height(m.height).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
AlignVertical(lipgloss.Center).
|
||||||
|
Render(body))
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// Package settings is the fullapp example's third page: a short list of
|
||||||
|
// toggles. It's the one page with keys of its own, so it's what proves the
|
||||||
|
// shell routes presses to the focused pane and lists that pane's bindings in
|
||||||
|
// the help bar.
|
||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toggle struct {
|
||||||
|
name string
|
||||||
|
on bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Toggle key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is the page. The cursor is a plain int: three lines don't need a
|
||||||
|
// list.Model behind them.
|
||||||
|
type Model struct {
|
||||||
|
keys keyMap
|
||||||
|
toggles []toggle
|
||||||
|
cursor int
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() Model {
|
||||||
|
return Model{
|
||||||
|
keys: keyMap{
|
||||||
|
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||||
|
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||||
|
Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")),
|
||||||
|
},
|
||||||
|
toggles: []toggle{
|
||||||
|
{name: "Nerd fonts", on: style.S.NerdFonts},
|
||||||
|
{name: "Notifications", on: true},
|
||||||
|
{name: "Telemetry", on: false},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
// The shell only sends these while this pane has focus, so there's
|
||||||
|
// no focused check to make here.
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
m.cursor = max(m.cursor-1, 0)
|
||||||
|
case key.Matches(msg, m.keys.Down):
|
||||||
|
m.cursor = min(m.cursor+1, len(m.toggles)-1)
|
||||||
|
case key.Matches(msg, m.keys.Toggle):
|
||||||
|
m.toggles[m.cursor].on = !m.toggles[m.cursor].on
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
rows := make([]string, len(m.toggles))
|
||||||
|
for i, t := range m.toggles {
|
||||||
|
cursor, box := " ", "[ ]"
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = "> "
|
||||||
|
}
|
||||||
|
if t.on {
|
||||||
|
box = "[x]"
|
||||||
|
}
|
||||||
|
line := cursor + box + " " + t.name
|
||||||
|
if i == m.cursor {
|
||||||
|
line = lipgloss.NewStyle().Foreground(style.S.Primary).Render(line)
|
||||||
|
}
|
||||||
|
rows[i] = line
|
||||||
|
}
|
||||||
|
|
||||||
|
return tea.NewView(lipgloss.NewStyle().
|
||||||
|
Width(m.width).Height(m.height).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
AlignVertical(lipgloss.Center).
|
||||||
|
Render(lipgloss.JoinVertical(lipgloss.Left, rows...)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelpBindings makes the page's keys show up in the shell's help bar while
|
||||||
|
// it has focus. It's the optional half of the contract: overview and metrics
|
||||||
|
// have no keys and don't implement it.
|
||||||
|
func (m Model) HelpBindings() []key.Binding {
|
||||||
|
return []key.Binding{m.keys.Up, m.keys.Down, m.keys.Toggle}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package sidebar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/list"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/bubbles"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NavItem struct {
|
||||||
|
Icon string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n NavItem) Title() string {
|
||||||
|
if n.Icon == "" {
|
||||||
|
return n.Name
|
||||||
|
}
|
||||||
|
return n.Icon + " " + n.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n NavItem) Description() string { return "" }
|
||||||
|
func (n NavItem) FilterValue() string { return n.Name }
|
||||||
|
|
||||||
|
type SelectMsg struct {
|
||||||
|
Index int
|
||||||
|
Item NavItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlurMsg is the sidebar asking to be given up: it goes out with the
|
||||||
|
// SelectMsg, on the grounds that picking an entry means you're done with the
|
||||||
|
// menu. Where focus lands instead is the host's call - the sidebar has no
|
||||||
|
// idea what else is on screen.
|
||||||
|
type BlurMsg struct{}
|
||||||
|
|
||||||
|
// blur is BlurMsg's command form.
|
||||||
|
func blur() tea.Msg { return BlurMsg{} }
|
||||||
|
|
||||||
|
type KeyMap struct {
|
||||||
|
Select key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
return KeyMap{
|
||||||
|
Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
KeyMap KeyMap
|
||||||
|
|
||||||
|
list list.Model
|
||||||
|
items []NavItem
|
||||||
|
selected int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(items ...NavItem) Model {
|
||||||
|
d := bubbles.NewDefaultDelegate()
|
||||||
|
d.ShowDescription = false
|
||||||
|
d.SetSpacing(0)
|
||||||
|
|
||||||
|
l := bubbles.NewList(listItems(items), 0, 0)
|
||||||
|
l.SetDelegate(d)
|
||||||
|
l.SetShowTitle(false)
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetShowPagination(false)
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
l.DisableQuitKeybindings()
|
||||||
|
|
||||||
|
return Model{
|
||||||
|
KeyMap: DefaultKeyMap(),
|
||||||
|
list: l,
|
||||||
|
items: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listItems(items []NavItem) []list.Item {
|
||||||
|
out := make([]list.Item, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
out[i] = item
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
if press, ok := msg.(tea.KeyPressMsg); ok && key.Matches(press, m.KeyMap.Select) {
|
||||||
|
// Only the interactive path asks for focus to move on. Select on its
|
||||||
|
// own doesn't, because the host also calls it to set the starting
|
||||||
|
// entry - which must not steal focus from anything.
|
||||||
|
return m, tea.Batch(m.Select(m.list.Index()), blur)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.list, cmd = m.list.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Select(index int) tea.Cmd {
|
||||||
|
if index < 0 || index >= len(m.items) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.selected = index
|
||||||
|
m.list.Select(index)
|
||||||
|
|
||||||
|
item := m.items[index]
|
||||||
|
return func() tea.Msg { return SelectMsg{Index: index, Item: item} }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) SetSize(width, height int) { m.list.SetSize(width, height) }
|
||||||
|
|
||||||
|
func (m Model) Selected() NavItem {
|
||||||
|
if m.selected < 0 || m.selected >= len(m.items) {
|
||||||
|
return NavItem{}
|
||||||
|
}
|
||||||
|
return m.items[m.selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) SelectedIndex() int { return m.selected }
|
||||||
|
|
||||||
|
func (m Model) Cursor() int { return m.list.Index() }
|
||||||
|
|
||||||
|
func (m Model) View() string { return m.list.View() }
|
||||||
|
|
||||||
|
func (m Model) HelpBindings() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
m.list.KeyMap.CursorUp,
|
||||||
|
m.list.KeyMap.CursorDown,
|
||||||
|
m.KeyMap.Select,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
// Command basic demonstrates the minimum layout needs: a 2x2 grid of plain
|
|
||||||
// panes, no custom border, ctrl+hjkl moving focus between them out of the
|
|
||||||
// box.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
// textPane is the simplest possible Pane: it just reports its own id, size
|
|
||||||
// and focus state. It still renders to exactly the width/height it was
|
|
||||||
// told (via lipgloss's own Width/Height), which is the one rule every Pane
|
|
||||||
// has to follow - layout composes View() output side by side and never
|
|
||||||
// pads it itself.
|
|
||||||
type textPane struct {
|
|
||||||
id string
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTextPane(id string) *textPane { return &textPane{id: id} }
|
|
||||||
|
|
||||||
func (p *textPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *textPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *textPane) View() string {
|
|
||||||
state := "blurred"
|
|
||||||
if p.focused {
|
|
||||||
state = "focused"
|
|
||||||
}
|
|
||||||
content := fmt.Sprintf("%s\n(%s, %dx%d)", p.id, state, p.w, p.h)
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Width(p.w).
|
|
||||||
Height(p.h).
|
|
||||||
AlignHorizontal(lipgloss.Center).
|
|
||||||
AlignVertical(lipgloss.Center).
|
|
||||||
Render(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// model is the actual top-level tea.Model: layout itself reserves no quit
|
|
||||||
// key (that's an app policy, not layout's to make), so the host wraps it
|
|
||||||
// and handles ctrl+c/q itself, same as any other custom component in this
|
|
||||||
// repo (see examples/tabs).
|
|
||||||
type model struct {
|
|
||||||
layout layout.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.layout.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch key.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.layout.Update(msg)
|
|
||||||
m.layout = updated.(layout.Model)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.layout.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := layout.HSplit(0.5,
|
|
||||||
layout.VSplit(0.5,
|
|
||||||
layout.Leaf("top-left", newTextPane("top-left")),
|
|
||||||
layout.Leaf("bottom-left", newTextPane("bottom-left")),
|
|
||||||
),
|
|
||||||
layout.VSplit(0.5,
|
|
||||||
layout.Leaf("top-right", newTextPane("top-right")),
|
|
||||||
layout.Leaf("bottom-right", newTextPane("bottom-right")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
m := model{layout: layout.New(root, layout.AsRoot())}
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// Command bordered demonstrates that layout draws no chrome of its own:
|
|
||||||
// each pane here owns its border and picks its color from FocusMsg/BlurMsg,
|
|
||||||
// via the optional layout.Bordered helper.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
type borderedPane struct {
|
|
||||||
id string
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBorderedPane(id string) *borderedPane { return &borderedPane{id: id} }
|
|
||||||
|
|
||||||
func (p *borderedPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *borderedPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *borderedPane) View() string {
|
|
||||||
inner := lipgloss.NewStyle().
|
|
||||||
Width(p.w - 2).
|
|
||||||
Height(p.h - 2).
|
|
||||||
AlignHorizontal(lipgloss.Center).
|
|
||||||
AlignVertical(lipgloss.Center).
|
|
||||||
Render(p.id)
|
|
||||||
// layout.Bordered already draws the border at exactly p.w x p.h, so the
|
|
||||||
// content it wraps must already be sized to w-2 x h-2 (border eats one
|
|
||||||
// cell on each side) - same rule as any other Pane, just one layer in.
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// model is the actual top-level tea.Model: layout itself reserves no quit
|
|
||||||
// key (that's an app policy, not layout's to make), so the host wraps it
|
|
||||||
// and handles ctrl+c/q itself, same as any other custom component in this
|
|
||||||
// repo (see examples/tabs).
|
|
||||||
type model struct {
|
|
||||||
layout layout.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.layout.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch key.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.layout.Update(msg)
|
|
||||||
m.layout = updated.(layout.Model)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.layout.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := layout.HSplit(0.3,
|
|
||||||
layout.Leaf("sidebar", newBorderedPane("sidebar")),
|
|
||||||
layout.VSplit(0.6,
|
|
||||||
layout.Leaf("main", newBorderedPane("main")),
|
|
||||||
layout.Leaf("log", newBorderedPane("log")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
m := model{layout: layout.New(root, layout.AsRoot())}
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// Command fullapp demonstrates a more realistic app shell: the sidebar
|
|
||||||
// leaf isn't a plain placeholder pane, it's a full layout.Model of its own
|
|
||||||
// (sidebar.New(), a themed list, see sidebar/sidebar.go) embedded exactly
|
|
||||||
// like the "router" one below - layout.Model implements Pane, so any
|
|
||||||
// package can hand back one to be wrapped in a Leaf, no adapter needed.
|
|
||||||
package first
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pane struct {
|
|
||||||
id string
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPane(id string) *pane { return &pane{id: id} }
|
|
||||||
|
|
||||||
func (p *pane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pane) View() string {
|
|
||||||
content := lipgloss.NewStyle().
|
|
||||||
Width(p.w - 2).Height(p.h - 2).
|
|
||||||
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
|
|
||||||
Render(p.id)
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements layout.HelpProvider, so every pane - at the top
|
|
||||||
// level or nested three levels deep, doesn't matter - shows up correctly
|
|
||||||
// in the single, outer help bar.
|
|
||||||
func (p *pane) HelpBindings() []key.Binding {
|
|
||||||
return []key.Binding{key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "use "+p.id))}
|
|
||||||
}
|
|
||||||
|
|
||||||
// editorPane keeps a counter (press +) that's otherwise irrelevant to the
|
|
||||||
// app but proves the point of router.Model owning pages permanently:
|
|
||||||
// switch to "Second" and back, and it's still there. A plain pane (like
|
|
||||||
// terminal below) would just get reconstructed from scratch every time if
|
|
||||||
// something recreated it on each switch instead of keeping it alive.
|
|
||||||
type editorPane struct {
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
count int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEditorPane() *editorPane { return &editorPane{} }
|
|
||||||
|
|
||||||
func (p *editorPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *editorPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
if msg.String() == "+" {
|
|
||||||
p.count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *editorPane) View() string {
|
|
||||||
content := lipgloss.NewStyle().
|
|
||||||
Width(p.w - 2).Height(p.h - 2).
|
|
||||||
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
|
|
||||||
Render(fmt.Sprintf("editor\n\npress + to increment: %d", p.count))
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements layout.HelpProvider.
|
|
||||||
func (p *editorPane) HelpBindings() []key.Binding {
|
|
||||||
return []key.Binding{key.NewBinding(key.WithKeys("+"), key.WithHelp("+", "increment"))}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkspace builds the "First" page's own nested layout.Model: note the
|
|
||||||
// lack of layout.AsRoot() here - only the outermost Model (see main) should
|
|
||||||
// render a help bar, or focused help would show up twice. second.
|
|
||||||
// NewWorkspace and sidebar.New() below are built the same way, same reason.
|
|
||||||
//
|
|
||||||
// This Model is built exactly once, by router.New(), and kept alive for
|
|
||||||
// the whole app's lifetime - router.Model just changes which page is
|
|
||||||
// rendered/routed to, it never reconstructs one. That's what lets
|
|
||||||
// editorPane's counter above survive switching to "Second" and back; see
|
|
||||||
// package router's own doc comment for why that ownership lives there
|
|
||||||
// and not in the sidebar.
|
|
||||||
func NewWorkspace() layout.Model {
|
|
||||||
root := layout.VSplit(0.7,
|
|
||||||
layout.Leaf("editor", newEditorPane()),
|
|
||||||
layout.Leaf("terminal", newPane("terminal")),
|
|
||||||
)
|
|
||||||
return layout.New(root)
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// Command fullapp demonstrates a more realistic app shell: the sidebar
|
|
||||||
// leaf isn't a plain placeholder pane, it's a full layout.Model of its own
|
|
||||||
// (sidebar.New(), a themed list, see sidebar/sidebar.go) embedded exactly
|
|
||||||
// like the "router" one below - layout.Model implements Pane, so any
|
|
||||||
// package can hand back one to be wrapped in a Leaf, no adapter needed.
|
|
||||||
//
|
|
||||||
// It also owns the app's single notification.Model (see second/second.go,
|
|
||||||
// which triggers toasts via notification.Show without ever holding a
|
|
||||||
// reference to it) and composites its toasts over the whole rendered
|
|
||||||
// layout, top-right - notification has no dependency on layout, so this is
|
|
||||||
// the same Render(background string) pattern as examples/notification, just
|
|
||||||
// with layout.Model.View() as the background instead of a plain string.
|
|
||||||
//
|
|
||||||
// Same story for the app's single modal.Model (see third/third.go), except
|
|
||||||
// modal is composited last, on top of notif's own output: a modal is meant
|
|
||||||
// to command the whole screen's attention, so it should flatten any toast
|
|
||||||
// already showing to the same dim gray as everything else, not leave it
|
|
||||||
// floating on top in full color.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/router"
|
|
||||||
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/sidebar"
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
"github.com/anotherhadi/ilovetui/modal"
|
|
||||||
"github.com/anotherhadi/ilovetui/notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
// model is the actual top-level tea.Model: layout itself reserves no quit
|
|
||||||
// key (that's an app policy, not layout's to make), so the host wraps it
|
|
||||||
// and handles ctrl+c/q itself, same as any other custom component in this
|
|
||||||
// repo (see examples/tabs).
|
|
||||||
type model struct {
|
|
||||||
layout layout.Model
|
|
||||||
notif notification.Model
|
|
||||||
modal modal.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
|
||||||
return tea.Batch(m.layout.Init(), m.notif.Init(), m.modal.Init())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch key.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, layoutCmd := m.layout.Update(msg)
|
|
||||||
m.layout = updated.(layout.Model)
|
|
||||||
|
|
||||||
var notifCmd, modalCmd tea.Cmd
|
|
||||||
m.notif, notifCmd = m.notif.Update(msg)
|
|
||||||
m.modal, modalCmd = m.modal.Update(msg)
|
|
||||||
|
|
||||||
return m, tea.Batch(layoutCmd, notifCmd, modalCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.modal.Render(m.notif.Render(m.layout.View())))
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := layout.HSplit(0.25,
|
|
||||||
layout.Leaf("sidebar", sidebar.New()),
|
|
||||||
layout.Leaf("router", router.New()),
|
|
||||||
).WithMaximum(20)
|
|
||||||
|
|
||||||
m := model{
|
|
||||||
layout: layout.New(root, layout.AsRoot()),
|
|
||||||
notif: notification.New(notification.WithPosition(notification.TopRight)),
|
|
||||||
modal: modal.New(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
// Package router owns every page's own layout.Model for the lifetime of
|
|
||||||
// the whole app, so switching pages never discards their state - only
|
|
||||||
// which one is currently active/rendered changes. It slots into the root
|
|
||||||
// tree's "router" leaf exactly like a single nested layout.Model would
|
|
||||||
// (see examples/layout/nested), because Model implements layout.Navigable
|
|
||||||
// itself on top of layout.Pane, delegating everything to whichever page is
|
|
||||||
// active: ctrl+hjkl, the help bar, and SendMsg/RequestFocusMsg addressed to
|
|
||||||
// ids inside that page all keep working transparently.
|
|
||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/first"
|
|
||||||
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/second"
|
|
||||||
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/third"
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SelectMsg asks Model to switch its active page, by id ("first", "second"
|
|
||||||
// or "third"). Sent by the sidebar via layout.SendMsg{Target: "router"} -
|
|
||||||
// it never holds a reference to Model either.
|
|
||||||
type SelectMsg struct{ Page string }
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
active string
|
|
||||||
pages map[string]layout.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() *Model {
|
|
||||||
return &Model{
|
|
||||||
active: "first",
|
|
||||||
pages: map[string]layout.Model{
|
|
||||||
"first": first.NewWorkspace(),
|
|
||||||
"second": second.NewWorkspace(),
|
|
||||||
"third": third.NewWorkspace(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) current() layout.Model { return m.pages[m.active] }
|
|
||||||
|
|
||||||
func (m *Model) Init() tea.Cmd {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
for _, p := range m.pages {
|
|
||||||
if cmd := p.Init(); cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
if sel, ok := msg.(SelectMsg); ok {
|
|
||||||
if _, exists := m.pages[sel.Page]; exists {
|
|
||||||
m.active = sel.Page
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if size, ok := msg.(layout.SizeMsg); ok {
|
|
||||||
// Every page needs to stay correctly sized even while hidden - the
|
|
||||||
// outer tree only re-sends SizeMsg when the "router" leaf's own
|
|
||||||
// Rect changes, never on a plain page switch, so a page that was
|
|
||||||
// inactive during a resize must still learn about it here, or it
|
|
||||||
// renders at a stale size whenever it becomes active again.
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
for id, p := range m.pages {
|
|
||||||
updated, cmd := p.Update(size)
|
|
||||||
m.pages[id] = updated.(layout.Model)
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.current().Update(msg)
|
|
||||||
m.pages[m.active] = updated.(layout.Model)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) View() string {
|
|
||||||
return m.current().View()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leaves, MoveFocus, Route, Focus and FocusedHelp implement
|
|
||||||
// layout.Navigable, delegating to whichever page is currently active.
|
|
||||||
|
|
||||||
func (m *Model) Leaves() []layout.LeafRect {
|
|
||||||
return m.current().Leaves()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) MoveFocus(dir layout.FocusDirection) bool {
|
|
||||||
return m.current().MoveFocus(dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
|
|
||||||
return m.current().Route(target, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Focus(id string) (bool, tea.Cmd) {
|
|
||||||
return m.current().Focus(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) FocusedHelp() []key.Binding {
|
|
||||||
return m.current().FocusedHelp()
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// Package second is the "Second" page's own workspace: deliberately a
|
|
||||||
// different shape from first's (a single pane holding a themed list, not an
|
|
||||||
// editor/terminal split), so swapping between the two via sidebar.Model's
|
|
||||||
// "enter" case is visibly a real change of sub-app, not just a relabeled
|
|
||||||
// copy of first. It also demonstrates notification.Show being called from
|
|
||||||
// deep inside a nested layout.Model, with nothing wiring it to the
|
|
||||||
// notification.Model that actually renders it - see main.go's model.View.
|
|
||||||
package second
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
bubbleslist "charm.land/bubbles/v2/list"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/bubbles"
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
"github.com/anotherhadi/ilovetui/notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
// kind is a list entry: a notification.Kind plus the title/message Show
|
|
||||||
// gets called with when it's selected.
|
|
||||||
type kind struct {
|
|
||||||
title string
|
|
||||||
message string
|
|
||||||
kind notification.Kind
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k kind) Title() string { return k.title }
|
|
||||||
func (k kind) Description() string { return "" }
|
|
||||||
func (k kind) FilterValue() string { return k.title }
|
|
||||||
|
|
||||||
type pane struct {
|
|
||||||
list bubbleslist.Model
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPane() *pane {
|
|
||||||
items := []bubbleslist.Item{
|
|
||||||
kind{title: "Info", message: "Just so you know.", kind: notification.Info},
|
|
||||||
kind{title: "Success", message: "Config written to disk.", kind: notification.Success},
|
|
||||||
kind{title: "Warning", message: "Disk space getting low on /dev/sda1.", kind: notification.Warning},
|
|
||||||
kind{title: "Error", message: "Failed to reach the remote host.", kind: notification.Error},
|
|
||||||
}
|
|
||||||
list := bubbles.NewList(items, 0, 0)
|
|
||||||
list.Title = "Notify"
|
|
||||||
// Same reasoning as sidebar.Model: the built-in help footer is
|
|
||||||
// redundant with layout's own centralized help bar (see HelpBindings
|
|
||||||
// below).
|
|
||||||
list.SetShowHelp(false)
|
|
||||||
return &pane{list: list}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
p.list.SetSize(p.w-2, p.h-2)
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !p.focused {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
|
|
||||||
if selected, ok := p.list.SelectedItem().(kind); ok {
|
|
||||||
return p, notification.Show(selected.title, selected.message, selected.kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
p.list, cmd = p.list.Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pane) View() string {
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, p.list.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements layout.HelpProvider.
|
|
||||||
func (p *pane) HelpBindings() []key.Binding {
|
|
||||||
return []key.Binding{
|
|
||||||
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "show notification")),
|
|
||||||
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
|
|
||||||
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkspace builds the "Second" page's own nested layout.Model. No
|
|
||||||
// layout.AsRoot() - see first.NewWorkspace's doc comment.
|
|
||||||
func NewWorkspace() layout.Model {
|
|
||||||
return layout.New(layout.Leaf("content", newPane()))
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package sidebar
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
bubbleslist "charm.land/bubbles/v2/list"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/bubbles"
|
|
||||||
"github.com/anotherhadi/ilovetui/examples/layout/fullapp/router"
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
// page is a sidebar entry: just an id (matching one of router.Model's
|
|
||||||
// own page keys) and a label. Model has no idea what a page actually is or
|
|
||||||
// how it's built - that's router's business entirely, this only needs
|
|
||||||
// to name it.
|
|
||||||
type page struct {
|
|
||||||
id string
|
|
||||||
title string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p page) Title() string { return p.title }
|
|
||||||
func (p page) Description() string { return "" }
|
|
||||||
func (p page) FilterValue() string { return p.title }
|
|
||||||
|
|
||||||
type Model struct {
|
|
||||||
id string // learned from SizeMsg.ID, needed as RequestFocusMsg.Source
|
|
||||||
list bubbleslist.Model
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func New() *Model {
|
|
||||||
items := []bubbleslist.Item{
|
|
||||||
page{id: "first", title: "First"},
|
|
||||||
page{id: "second", title: "Second"},
|
|
||||||
page{id: "third", title: "Third"},
|
|
||||||
}
|
|
||||||
list := bubbles.NewList(items, 0, 0)
|
|
||||||
list.Title = "Sidebar"
|
|
||||||
// list's own built-in help footer is redundant - Model already feeds
|
|
||||||
// layout's single centralized help bar via HelpBindings below - and at
|
|
||||||
// a narrow sidebar width it word-wraps onto multiple lines, which
|
|
||||||
// list.SetSize doesn't account for: list.View() ends up taller than
|
|
||||||
// the height it was given, breaking layout's "render exactly h" rule
|
|
||||||
// and pushing the border past the bottom of the pane.
|
|
||||||
list.SetShowHelp(false)
|
|
||||||
return &Model{list: list}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
m.id, m.w, m.h = msg.ID, msg.Width, msg.Height
|
|
||||||
m.list.SetSize(m.w-2, m.h-2)
|
|
||||||
case layout.FocusMsg:
|
|
||||||
m.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
m.focused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.focused {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
|
|
||||||
if selected, ok := m.list.SelectedItem().(page); ok {
|
|
||||||
// tea.Sequence, not tea.Batch: the page switch must land
|
|
||||||
// before the focus jump, or RequestFocusMsg could cascade
|
|
||||||
// into router while it's still showing the previous page
|
|
||||||
// (Batch runs both concurrently with no ordering guarantee).
|
|
||||||
return m, tea.Sequence(
|
|
||||||
func() tea.Msg {
|
|
||||||
return layout.SendMsg{Target: "router", Msg: router.SelectMsg{Page: selected.id}}
|
|
||||||
},
|
|
||||||
func() tea.Msg {
|
|
||||||
return layout.RequestFocusMsg{Source: m.id, Target: "router"}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.list, cmd = m.list.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) View() string {
|
|
||||||
return layout.Bordered(m.focused, m.w, m.h, m.list.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) HelpBindings() []key.Binding {
|
|
||||||
return []key.Binding{
|
|
||||||
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open page")),
|
|
||||||
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
|
|
||||||
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
// Package third is the "Third" page's own workspace: a single pane showing
|
|
||||||
// modal.Show being called from deep inside a nested layout.Model, exactly
|
|
||||||
// like second/second.go does for notification.Show - modal has no
|
|
||||||
// dependency on layout either, so nothing here needs a reference to the
|
|
||||||
// modal.Model that actually renders it (see main.go's model.View).
|
|
||||||
package third
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
bubbleslist "charm.land/bubbles/v2/list"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/bubbles"
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
"github.com/anotherhadi/ilovetui/modal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// action is a list entry: the title/content Show gets called with when it's
|
|
||||||
// selected, and whether closing it needs an explicit "y" (a destructive
|
|
||||||
// confirm) or any key at all (a plain info popup).
|
|
||||||
type action struct {
|
|
||||||
title, content string
|
|
||||||
confirm bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a action) Title() string { return a.title }
|
|
||||||
func (a action) Description() string { return "" }
|
|
||||||
func (a action) FilterValue() string { return a.title }
|
|
||||||
|
|
||||||
type pane struct {
|
|
||||||
list bubbleslist.Model
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
// open/confirm track the modal this pane itself opened, so it knows to
|
|
||||||
// swallow keys instead of forwarding them to the list underneath while
|
|
||||||
// it's up - modal.Model has no notion of "focus" of its own, the pane
|
|
||||||
// that triggered it is responsible for gating input while it's open.
|
|
||||||
open bool
|
|
||||||
confirm bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPane() *pane {
|
|
||||||
items := []bubbleslist.Item{
|
|
||||||
action{title: "Delete file", content: "This can't be undone.\n\ny: confirm esc: cancel", confirm: true},
|
|
||||||
action{title: "About", content: "modal renders a centered popup over a\nflat-dimmed background.\n\nesc: close"},
|
|
||||||
}
|
|
||||||
list := bubbles.NewList(items, 0, 0)
|
|
||||||
list.Title = "Modal"
|
|
||||||
// Same reasoning as sidebar/second: redundant with layout's own
|
|
||||||
// centralized help bar (see HelpBindings below).
|
|
||||||
list.SetShowHelp(false)
|
|
||||||
return &pane{list: list}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
p.list.SetSize(p.w-2, p.h-2)
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !p.focused {
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
if p.open {
|
|
||||||
if !p.confirm || key.String() == "y" || key.String() == "esc" {
|
|
||||||
p.open = false
|
|
||||||
return p, modal.Close()
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if key.String() == "enter" {
|
|
||||||
if selected, ok := p.list.SelectedItem().(action); ok {
|
|
||||||
p.open, p.confirm = true, selected.confirm
|
|
||||||
return p, modal.Show(selected.title+"?", selected.content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
p.list, cmd = p.list.Update(msg)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pane) View() string {
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, p.list.View())
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements layout.HelpProvider.
|
|
||||||
func (p *pane) HelpBindings() []key.Binding {
|
|
||||||
return []key.Binding{
|
|
||||||
key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "open modal")),
|
|
||||||
key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "go down")),
|
|
||||||
key.NewBinding(key.WithKeys("k"), key.WithHelp("k", "go up")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkspace builds the "Third" page's own nested layout.Model. No
|
|
||||||
// layout.AsRoot() - see first.NewWorkspace's doc comment.
|
|
||||||
func NewWorkspace() layout.Model {
|
|
||||||
return layout.New(layout.Leaf("content", newPane()))
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// Command help demonstrates the dynamic help bar: each pane implements
|
|
||||||
// layout.HelpProvider (or doesn't) and the bottom bar always reflects
|
|
||||||
// whichever one is currently focused, with no wiring beyond that.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
// helpfulPane implements layout.HelpProvider with a binding or two of its
|
|
||||||
// own, on top of the usual Size/Focus/Blur bookkeeping.
|
|
||||||
type helpfulPane struct {
|
|
||||||
id string
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
bindings []key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
func newHelpfulPane(id string, bindings []key.Binding) *helpfulPane {
|
|
||||||
return &helpfulPane{id: id, bindings: bindings}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *helpfulPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *helpfulPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *helpfulPane) View() string {
|
|
||||||
content := lipgloss.NewStyle().
|
|
||||||
Width(p.w - 2).Height(p.h - 2).
|
|
||||||
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
|
|
||||||
Render(p.id)
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements layout.HelpProvider.
|
|
||||||
func (p *helpfulPane) HelpBindings() []key.Binding { return p.bindings }
|
|
||||||
|
|
||||||
// silentPane deliberately does NOT implement layout.HelpProvider, to show
|
|
||||||
// that the help bar just falls back to layout's own controls (ctrl+hjkl,
|
|
||||||
// ?) instead of disappearing or erroring when it's focused.
|
|
||||||
type silentPane struct {
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *silentPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *silentPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *silentPane) View() string {
|
|
||||||
content := lipgloss.NewStyle().
|
|
||||||
Width(p.w - 2).Height(p.h - 2).
|
|
||||||
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
|
|
||||||
Render("no help bindings\n(watch the bar below)")
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// model is the actual top-level tea.Model: layout itself reserves no quit
|
|
||||||
// key (that's an app policy, not layout's to make), so the host wraps it
|
|
||||||
// and handles ctrl+c/q itself, same as any other custom component in this
|
|
||||||
// repo (see examples/tabs).
|
|
||||||
type model struct {
|
|
||||||
layout layout.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.layout.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch key.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.layout.Update(msg)
|
|
||||||
m.layout = updated.(layout.Model)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.layout.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
writer := newHelpfulPane("writer", []key.Binding{
|
|
||||||
key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save")),
|
|
||||||
key.NewBinding(key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "undo")),
|
|
||||||
})
|
|
||||||
browser := newHelpfulPane("browser", []key.Binding{
|
|
||||||
key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
|
|
||||||
})
|
|
||||||
|
|
||||||
root := layout.HSplit(0.34,
|
|
||||||
layout.Leaf("writer", writer),
|
|
||||||
layout.HSplit(0.5,
|
|
||||||
layout.Leaf("browser", browser),
|
|
||||||
layout.Leaf("scratch", &silentPane{}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
m := model{layout: layout.New(root, layout.AsRoot())}
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
// Command messaging demonstrates the two ways panes talk without holding a
|
|
||||||
// reference to each other: "control" sends arbitrary commands to "editor"
|
|
||||||
// by id via SendMsg (1/2/3 keys), and asks layout to move focus there via
|
|
||||||
// RequestFocusMsg (enter key) - the same pattern a real sidebar would use
|
|
||||||
// to both drive and jump to a content pane it selected.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
// commandMsg is what "control" sends to "editor" - an app-defined message,
|
|
||||||
// entirely opaque to layout itself (see SendMsg).
|
|
||||||
type commandMsg struct{ text string }
|
|
||||||
|
|
||||||
type controlPane struct {
|
|
||||||
id string // learned from SizeMsg.ID, needed as RequestFocusMsg.Source
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *controlPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *controlPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.id, p.w, p.h = msg.ID, msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch msg.String() {
|
|
||||||
case "1", "2", "3":
|
|
||||||
text := "command " + msg.String()
|
|
||||||
return p, func() tea.Msg {
|
|
||||||
return layout.SendMsg{Target: "editor", Msg: commandMsg{text: text}}
|
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
return p, func() tea.Msg {
|
|
||||||
return layout.RequestFocusMsg{Source: p.id, Target: "editor"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *controlPane) View() string {
|
|
||||||
content := "control\n\n1/2/3: send a command\nenter: focus editor"
|
|
||||||
inner := lipgloss.NewStyle().Width(p.w - 2).Height(p.h - 2).Render(content)
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
type editorPane struct {
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
last string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *editorPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *editorPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
case commandMsg:
|
|
||||||
p.last = msg.text
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *editorPane) View() string {
|
|
||||||
last := p.last
|
|
||||||
if last == "" {
|
|
||||||
last = "(nothing yet)"
|
|
||||||
}
|
|
||||||
content := fmt.Sprintf("editor\n\nlast command received:\n%s", last)
|
|
||||||
inner := lipgloss.NewStyle().Width(p.w - 2).Height(p.h - 2).Render(content)
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
// model is the actual top-level tea.Model: layout itself reserves no quit
|
|
||||||
// key (that's an app policy, not layout's to make), so the host wraps it
|
|
||||||
// and handles ctrl+c/q itself, same as any other custom component in this
|
|
||||||
// repo (see examples/tabs).
|
|
||||||
type model struct {
|
|
||||||
layout layout.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.layout.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch key.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.layout.Update(msg)
|
|
||||||
m.layout = updated.(layout.Model)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.layout.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := layout.HSplit(0.35,
|
|
||||||
layout.Leaf("control", &controlPane{}),
|
|
||||||
layout.Leaf("editor", &editorPane{}),
|
|
||||||
)
|
|
||||||
m := model{layout: layout.New(root, layout.AsRoot())}
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// Command nested demonstrates composability: "workspace" is itself a full
|
|
||||||
// layout.Model (its own two-pane split) embedded as an ordinary Leaf inside
|
|
||||||
// the outer tree. ctrl+hjkl bubbles in and out of it transparently, and
|
|
||||||
// only the outer Model shows a help bar - the inner one is built without
|
|
||||||
// layout.AsRoot(), see newWorkspace.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pane struct {
|
|
||||||
id string
|
|
||||||
w, h int
|
|
||||||
focused bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPane(id string) *pane { return &pane{id: id} }
|
|
||||||
|
|
||||||
func (p *pane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *pane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case layout.SizeMsg:
|
|
||||||
p.w, p.h = msg.Width, msg.Height
|
|
||||||
case layout.FocusMsg:
|
|
||||||
p.focused = true
|
|
||||||
case layout.BlurMsg:
|
|
||||||
p.focused = false
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *pane) View() string {
|
|
||||||
content := lipgloss.NewStyle().
|
|
||||||
Width(p.w - 2).Height(p.h - 2).
|
|
||||||
AlignHorizontal(lipgloss.Center).AlignVertical(lipgloss.Center).
|
|
||||||
Render(p.id)
|
|
||||||
return layout.Bordered(p.focused, p.w, p.h, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements layout.HelpProvider, so every pane - at the top
|
|
||||||
// level or nested three levels deep, doesn't matter - shows up correctly
|
|
||||||
// in the single, outer help bar.
|
|
||||||
func (p *pane) HelpBindings() []key.Binding {
|
|
||||||
return []key.Binding{key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "use "+p.id))}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newWorkspace builds the nested layout.Model: note the lack of
|
|
||||||
// layout.AsRoot() here - only the outermost Model (see main) should render
|
|
||||||
// a help bar, or focused help would show up twice.
|
|
||||||
func newWorkspace() layout.Model {
|
|
||||||
root := layout.VSplit(0.7,
|
|
||||||
layout.Leaf("editor", newPane("editor")),
|
|
||||||
layout.Leaf("terminal", newPane("terminal")),
|
|
||||||
)
|
|
||||||
return layout.New(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
// model is the actual top-level tea.Model: layout itself reserves no quit
|
|
||||||
// key (that's an app policy, not layout's to make), so the host wraps it
|
|
||||||
// and handles ctrl+c/q itself, same as any other custom component in this
|
|
||||||
// repo (see examples/tabs).
|
|
||||||
type model struct {
|
|
||||||
layout layout.Model
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd { return m.layout.Init() }
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
switch key.String() {
|
|
||||||
case "ctrl+c", "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.layout.Update(msg)
|
|
||||||
m.layout = updated.(layout.Model)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() tea.View {
|
|
||||||
view := tea.NewView(m.layout.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := layout.HSplit(0.25,
|
|
||||||
layout.Leaf("sidebar", newPane("sidebar")).WithMinimum(20),
|
|
||||||
layout.Leaf("workspace", newWorkspace()),
|
|
||||||
)
|
|
||||||
m := model{layout: layout.New(root, layout.AsRoot())}
|
|
||||||
|
|
||||||
if _, err := tea.NewProgram(m).Run(); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+38
-11
@@ -11,10 +11,36 @@ import (
|
|||||||
"github.com/anotherhadi/ilovetui/style"
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
)
|
)
|
||||||
|
|
||||||
const confirmID = "confirm"
|
// confirmedMsg is what the confirmation modal reports back with. The app
|
||||||
|
// listens for it like any other message - it never holds a reference to the
|
||||||
|
// modal, and the modal never knows what confirming means.
|
||||||
|
type confirmedMsg struct{}
|
||||||
|
|
||||||
|
// confirm is the modal's content: a model, so it owns its own keys. The host
|
||||||
|
// no longer has to ask which modal is on top to know where "y" should go.
|
||||||
|
type confirm struct{}
|
||||||
|
|
||||||
|
func (c confirm) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
|
||||||
|
// Report back and close itself: Close() is a package-level command,
|
||||||
|
// so the content needs no reference to the modal.Model either.
|
||||||
|
return c, tea.Batch(
|
||||||
|
func() tea.Msg { return confirmedMsg{} },
|
||||||
|
modal.Close(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c confirm) View() tea.View {
|
||||||
|
return tea.NewView("This can't be undone.\n\ny: confirm esc: cancel")
|
||||||
|
}
|
||||||
|
|
||||||
type model struct {
|
type model struct {
|
||||||
m modal.Model
|
m modal.Model
|
||||||
|
deleted bool
|
||||||
width, height int
|
width, height int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,29 +56,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.width, m.height = msg.Width, msg.Height
|
m.width, m.height = msg.Width, msg.Height
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case confirmedMsg:
|
||||||
|
m.deleted = true
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "q":
|
case "ctrl+c", "q":
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case "m":
|
case "m":
|
||||||
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel",
|
return m, modal.Show("Delete file?", confirm{})
|
||||||
modal.WithID(confirmID))
|
|
||||||
|
|
||||||
case "n":
|
case "n":
|
||||||
if m.m.Open() {
|
if m.m.Open() {
|
||||||
return m, modal.Show("Really sure?", "There's no undo for this one either.")
|
return m, modal.Show("Really sure?", modal.Text("There's no undo for this one either."))
|
||||||
}
|
}
|
||||||
|
|
||||||
case "esc":
|
case "esc":
|
||||||
if m.m.Open() {
|
if m.m.Open() {
|
||||||
return m, modal.Close()
|
return m, modal.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
case "y":
|
|
||||||
if m.m.TopID() == confirmID {
|
|
||||||
return m, modal.Dismiss(confirmID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +87,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m model) View() tea.View {
|
func (m model) View() tea.View {
|
||||||
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
|
title := lipgloss.NewStyle().Bold(true).Foreground(style.S.Primary).Render("My App")
|
||||||
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(
|
text := "Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal."
|
||||||
"Some regular content, styled with theme colors,\nso you can see it turn flat gray behind the modal.")
|
if m.deleted {
|
||||||
|
text = "File deleted - the modal's content reported back\nwith its own message, and closed itself."
|
||||||
|
}
|
||||||
|
body := lipgloss.NewStyle().Foreground(style.S.Text).Render(text)
|
||||||
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
|
help := lipgloss.NewStyle().Foreground(style.S.Subtle).Render(
|
||||||
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
|
"m: open modal n: open nested modal y: confirm esc: cancel q: quit")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Command sidebar is the whole app-shell pattern in one file: a sidebar on
|
||||||
|
// the left, an ordinary tea.Model on the right, a global help bar at the
|
||||||
|
// bottom, tab to move focus between the two. No layout package involved -
|
||||||
|
// lipgloss.JoinHorizontal/JoinVertical and style.RenderWithTitle already do
|
||||||
|
// all of it, and the panes stay plain tea.Models.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/list"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/bubbles"
|
||||||
|
"github.com/anotherhadi/ilovetui/helpbar"
|
||||||
|
"github.com/anotherhadi/ilovetui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sidebarWidth is the sidebar's total width, border included.
|
||||||
|
const sidebarWidth = 24
|
||||||
|
|
||||||
|
// HelpProvider is the only contract in this pattern, and it's optional: a
|
||||||
|
// pane that implements it gets its own bindings listed in the global help
|
||||||
|
// bar while it's focused. A pane that doesn't just contributes nothing.
|
||||||
|
type HelpProvider interface {
|
||||||
|
HelpBindings() []key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- shell keys
|
||||||
|
|
||||||
|
type keyMap struct {
|
||||||
|
Focus key.Binding
|
||||||
|
Help key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultKeyMap() keyMap {
|
||||||
|
return keyMap{
|
||||||
|
Focus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch pane")),
|
||||||
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
|
Quit: key.NewBinding(key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the shell
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
sidebar list.Model
|
||||||
|
content tea.Model
|
||||||
|
help helpbar.Model
|
||||||
|
keys keyMap
|
||||||
|
|
||||||
|
contentFocused bool
|
||||||
|
w, h int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModel() model {
|
||||||
|
items := []list.Item{page("Overview"), page("Metrics"), page("Settings")}
|
||||||
|
sidebar := bubbles.NewList(items, sidebarWidth-2, 0)
|
||||||
|
sidebar.SetShowTitle(false)
|
||||||
|
sidebar.SetShowStatusBar(false)
|
||||||
|
sidebar.SetShowHelp(false)
|
||||||
|
|
||||||
|
keys := defaultKeyMap()
|
||||||
|
return model{
|
||||||
|
sidebar: sidebar,
|
||||||
|
content: newCounter(),
|
||||||
|
help: helpbar.New(helpbar.WithToggle(keys.Help), helpbar.WithGlobal(keys.Focus, keys.Quit)),
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd { return m.content.Init() }
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.w, m.h = msg.Width, msg.Height
|
||||||
|
return m.resize()
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Quit):
|
||||||
|
return m, tea.Quit
|
||||||
|
case key.Matches(msg, m.keys.Help):
|
||||||
|
// Expanding the bar takes rows away from the panes.
|
||||||
|
m.help, _ = m.help.Update(msg)
|
||||||
|
return m.resize()
|
||||||
|
case key.Matches(msg, m.keys.Focus):
|
||||||
|
m.contentFocused = !m.contentFocused
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
// Only the focused pane sees key presses.
|
||||||
|
if m.contentFocused {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.content, cmd = m.content.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.sidebar, cmd = m.sidebar.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else (ticks, HTTP responses...) goes to both, so a blurred
|
||||||
|
// pane keeps working.
|
||||||
|
var sidebarCmd, contentCmd tea.Cmd
|
||||||
|
m.sidebar, sidebarCmd = m.sidebar.Update(msg)
|
||||||
|
m.content, contentCmd = m.content.Update(msg)
|
||||||
|
return m, tea.Batch(sidebarCmd, contentCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyHeight is the height left for the two panes once the help bar has
|
||||||
|
// taken its share. resize and View both go through it so they can't disagree.
|
||||||
|
func (m model) bodyHeight() int {
|
||||||
|
return max(m.h-m.help.Height(m.focusedHelp()...), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) resize() (model, tea.Cmd) {
|
||||||
|
if m.w <= 0 || m.h <= 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.help.SetWidth(m.w)
|
||||||
|
inner := style.ContentHeight(m.bodyHeight())
|
||||||
|
|
||||||
|
m.sidebar.SetSize(sidebarWidth-2, inner)
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.content, cmd = m.content.Update(tea.WindowSizeMsg{
|
||||||
|
Width: max(m.w-sidebarWidth-2, 0), Height: inner,
|
||||||
|
})
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusedHelp is the focused pane's own bindings, if it offers any.
|
||||||
|
func (m model) focusedHelp() []key.Binding {
|
||||||
|
if m.contentFocused {
|
||||||
|
if hp, ok := m.content.(HelpProvider); ok {
|
||||||
|
return hp.HelpBindings()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() tea.View {
|
||||||
|
if m.w <= 0 || m.h <= 0 {
|
||||||
|
return tea.NewView("")
|
||||||
|
}
|
||||||
|
helpBar := m.help.View(m.focusedHelp()...)
|
||||||
|
bodyH := m.bodyHeight()
|
||||||
|
|
||||||
|
left := style.RenderWithTitle(
|
||||||
|
panel(!m.contentFocused), "Menu", m.sidebar.View(), sidebarWidth, bodyH)
|
||||||
|
right := style.RenderWithTitle(
|
||||||
|
panel(m.contentFocused), "Content", m.content.View().Content, m.w-sidebarWidth, bodyH)
|
||||||
|
|
||||||
|
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
|
||||||
|
helpBar,
|
||||||
|
))
|
||||||
|
view.AltScreen = true
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// panel picks the bordered panel style matching a pane's focus state.
|
||||||
|
func panel(focused bool) lipgloss.Style {
|
||||||
|
if focused {
|
||||||
|
return style.S.PanelFocused
|
||||||
|
}
|
||||||
|
return style.S.Panel
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- the right-hand pane
|
||||||
|
|
||||||
|
// counter is an ordinary tea.Model - nothing about it knows it's living in a
|
||||||
|
// shell. It implements HelpProvider purely to appear in the help bar.
|
||||||
|
type counter struct {
|
||||||
|
n int
|
||||||
|
w, h int
|
||||||
|
keys struct{ Inc, Dec key.Binding }
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCounter() *counter {
|
||||||
|
c := &counter{}
|
||||||
|
c.keys.Inc = key.NewBinding(key.WithKeys("+", "k"), key.WithHelp("+/k", "increment"))
|
||||||
|
c.keys.Dec = key.NewBinding(key.WithKeys("-", "j"), key.WithHelp("-/j", "decrement"))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counter) Init() tea.Cmd { return nil }
|
||||||
|
|
||||||
|
func (c *counter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
c.w, c.h = msg.Width, msg.Height
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, c.keys.Inc):
|
||||||
|
c.n++
|
||||||
|
case key.Matches(msg, c.keys.Dec):
|
||||||
|
c.n--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counter) View() tea.View {
|
||||||
|
return tea.NewView(lipgloss.NewStyle().
|
||||||
|
Width(c.w).Height(c.h).
|
||||||
|
AlignHorizontal(lipgloss.Center).
|
||||||
|
AlignVertical(lipgloss.Center).
|
||||||
|
Render(fmt.Sprintf("count: %d", c.n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *counter) HelpBindings() []key.Binding {
|
||||||
|
return []key.Binding{c.keys.Inc, c.keys.Dec}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- sidebar items
|
||||||
|
|
||||||
|
type page string
|
||||||
|
|
||||||
|
func (p page) Title() string { return string(p) }
|
||||||
|
func (p page) Description() string { return "" }
|
||||||
|
func (p page) FilterValue() string { return string(p) }
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if _, err := tea.NewProgram(newModel()).Run(); err != nil {
|
||||||
|
fmt.Println("Error running program:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Helpbar
|
||||||
|
|
||||||
|
A responsive help bar: one line of key bindings that expands, on demand, into a multi-column view reflowed to use as many columns as the available width allows.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/anotherhadi/ilovetui/helpbar"
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
help helpbar.Model
|
||||||
|
keys keyMap
|
||||||
|
h int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newModel() model {
|
||||||
|
keys := defaultKeyMap()
|
||||||
|
return model{
|
||||||
|
help: helpbar.New(
|
||||||
|
helpbar.WithToggle(keys.Help), // '?' expands/collapses
|
||||||
|
helpbar.WithGlobal(keys.Focus, keys.Quit), // always shown
|
||||||
|
),
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.h = msg.Height
|
||||||
|
m.help.SetWidth(msg.Width)
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
m.help, _ = m.help.Update(msg) // flips ShowAll on the toggle binding
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) View() tea.View {
|
||||||
|
bar := m.help.View(m.focused().HelpBindings()...)
|
||||||
|
body := renderBody(m.h - lipgloss.Height(bar))
|
||||||
|
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, bar))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Global vs. contextual bindings
|
||||||
|
|
||||||
|
Bindings come from two places, and they render in this order:
|
||||||
|
|
||||||
|
1. The `WithToggle` binding, so the way to expand the bar always leads.
|
||||||
|
2. `WithGlobal` bindings - what your app reserves for itself (quit, switch pane...), set once.
|
||||||
|
3. Contextual bindings, passed to `View` at render time.
|
||||||
|
|
||||||
|
Contextual bindings are meant to change from render to render, so the bar can track whatever component currently has focus. Nothing in the package knows what "focus" means for your app - you just hand it a different slice:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (m model) focusedHelp() []key.Binding {
|
||||||
|
if m.contentFocused {
|
||||||
|
return m.content.HelpBindings()
|
||||||
|
}
|
||||||
|
return []key.Binding{m.sidebar.KeyMap.CursorUp, m.sidebar.KeyMap.CursorDown}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Disabled bindings (`key.Binding.SetEnabled(false)`) are dropped before layout, so the reflow never
|
||||||
|
budgets width for something that won't be drawn.
|
||||||
|
|
||||||
|
## Reserving room for the bar
|
||||||
|
|
||||||
|
The bar's height depends on its content and on whether it's expanded, so ask it rather than assuming
|
||||||
|
a fixed number of rows:
|
||||||
|
|
||||||
|
```go
|
||||||
|
body := m.height - m.help.Height(contextual...)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Height` is exactly `lipgloss.Height` of what `View` returns for the same arguments, so the two can
|
||||||
|
never disagree about where the bar begins. An empty bar (no bindings, or no width set) renders `""`
|
||||||
|
and takes zero rows.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
Defaults come from the shared `style` theme. Override with `WithStyles`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
helpbar.New(helpbar.WithStyles(myStyles)) // help.Styles from charm.land/bubbles/v2/help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- `examples/sidebar` uses it as an app-wide bar tracking the focused pane.
|
||||||
|
- `examples/app` full app with a single help bar
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Package helpbar is a responsive help bar: a single line of key bindings
|
||||||
|
// that expands, on demand, into a multi-column view reflowed to use as many
|
||||||
|
// columns as the available width allows.
|
||||||
|
//
|
||||||
|
// It has no dependency on any particular layout or container - it's just a
|
||||||
|
// component that takes a width and some bindings and returns a string, so it
|
||||||
|
// works as well under a plain lipgloss.JoinVertical as anywhere else.
|
||||||
|
//
|
||||||
|
// Bindings come from two places. Global ones (quit, toggle help, whatever
|
||||||
|
// your app reserves for itself) are set once via WithGlobal and always shown
|
||||||
|
// first. Contextual ones are passed to View at render time, so the bar can
|
||||||
|
// track whatever component currently has focus:
|
||||||
|
//
|
||||||
|
// bar := m.help.View(m.focused().HelpBindings()...)
|
||||||
|
// body := m.height - lipgloss.Height(bar)
|
||||||
|
package helpbar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"charm.land/bubbles/v2/help"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/anotherhadi/ilovetui/bubbles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is a help bar. The zero value isn't usable - build one with New.
|
||||||
|
type Model struct {
|
||||||
|
// ShowAll switches between the one-line short view and the full
|
||||||
|
// multi-column view. Set it directly, or let WithToggle bind a key to
|
||||||
|
// it and have Update flip it for you.
|
||||||
|
ShowAll bool
|
||||||
|
|
||||||
|
help help.Model
|
||||||
|
global []key.Binding
|
||||||
|
toggle key.Binding
|
||||||
|
width int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option configures a Model at construction.
|
||||||
|
type Option func(*Model)
|
||||||
|
|
||||||
|
// WithGlobal sets the bindings shown before the contextual ones on every
|
||||||
|
// render - the keys your app reserves for itself regardless of what's
|
||||||
|
// focused.
|
||||||
|
func WithGlobal(bindings ...key.Binding) Option {
|
||||||
|
return func(m *Model) { m.global = bindings }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithToggle makes Update flip ShowAll when b matches, and lists b ahead of
|
||||||
|
// every other binding (including WithGlobal's) so the way to expand the bar
|
||||||
|
// is always the first thing shown. Without it, Update ignores key presses
|
||||||
|
// and toggling ShowAll is entirely up to the caller.
|
||||||
|
func WithToggle(b key.Binding) Option {
|
||||||
|
return func(m *Model) { m.toggle = b }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithStyles overrides the themed default styles.
|
||||||
|
func WithStyles(s help.Styles) Option {
|
||||||
|
return func(m *Model) { m.help.Styles = s }
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a help bar themed from the shared style package.
|
||||||
|
func New(opts ...Option) Model {
|
||||||
|
m := Model{help: bubbles.NewHelp()}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(&m)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWidth sets the width the bar renders within. Nothing is shown until
|
||||||
|
// this is called with a positive value - typically from your
|
||||||
|
// tea.WindowSizeMsg handler.
|
||||||
|
func (m *Model) SetWidth(w int) {
|
||||||
|
m.width = w
|
||||||
|
m.help.SetWidth(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width returns the width last given to SetWidth.
|
||||||
|
func (m Model) Width() int { return m.width }
|
||||||
|
|
||||||
|
// Update flips ShowAll when a key press matches WithToggle's binding. It's
|
||||||
|
// optional: a Model built without WithToggle ignores every message, and you
|
||||||
|
// can always set ShowAll yourself instead.
|
||||||
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
if keyMsg, ok := msg.(tea.KeyPressMsg); ok && key.Matches(keyMsg, m.toggle) {
|
||||||
|
m.ShowAll = !m.ShowAll
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the bar: one line while ShowAll is false, otherwise the full
|
||||||
|
// multi-column view. contextual bindings follow the toggle and global ones,
|
||||||
|
// and are meant to change from render to render as focus moves.
|
||||||
|
//
|
||||||
|
// Returns "" when no width has been set or nothing is left to show, in which
|
||||||
|
// case the bar occupies no rows at all.
|
||||||
|
func (m Model) View(contextual ...key.Binding) string {
|
||||||
|
bindings := m.bindings(contextual)
|
||||||
|
if len(bindings) == 0 || m.width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !m.ShowAll {
|
||||||
|
return m.help.ShortHelpView(bindings)
|
||||||
|
}
|
||||||
|
return m.help.FullHelpView(m.columns(bindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height is the number of rows View would take for the same bindings. Use it
|
||||||
|
// to work out how much room is left for the rest of your UI. View is the
|
||||||
|
// source of truth: this is exactly lipgloss.Height of its output, so the two
|
||||||
|
// can't disagree about where the bar begins.
|
||||||
|
func (m Model) Height(contextual ...key.Binding) int {
|
||||||
|
view := m.View(contextual...)
|
||||||
|
if view == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return lipgloss.Height(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindings is the full ordered list: the toggle first (so the way to expand
|
||||||
|
// the bar always leads), then global, then contextual. Disabled bindings are
|
||||||
|
// dropped here so column reflow never budgets width for something
|
||||||
|
// help.FullHelpView will skip anyway.
|
||||||
|
func (m Model) bindings(contextual []key.Binding) []key.Binding {
|
||||||
|
all := make([]key.Binding, 0, 1+len(m.global)+len(contextual))
|
||||||
|
if m.toggle.Enabled() {
|
||||||
|
all = append(all, m.toggle)
|
||||||
|
}
|
||||||
|
for _, b := range append(append([]key.Binding{}, m.global...), contextual...) {
|
||||||
|
if b.Enabled() {
|
||||||
|
all = append(all, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
// columns reflows bindings into as many columns as fit within the bar's
|
||||||
|
// width, which is the same as using as few rows as possible. It walks row
|
||||||
|
// counts upward and returns the first arrangement that fits, so the result is
|
||||||
|
// the widest (fewest-rows) layout the width allows.
|
||||||
|
//
|
||||||
|
// help.FullHelpView fills each column top to bottom, so a group is a column,
|
||||||
|
// not a row.
|
||||||
|
func (m Model) columns(bindings []key.Binding) [][]key.Binding {
|
||||||
|
if m.width <= 0 {
|
||||||
|
return [][]key.Binding{bindings}
|
||||||
|
}
|
||||||
|
for rows := 1; rows < len(bindings); rows++ {
|
||||||
|
groups := chunkColumns(bindings, rows)
|
||||||
|
if m.renderedWidth(groups) <= m.width {
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Everything in one column: the narrowest arrangement possible. It may
|
||||||
|
// still overflow, in which case help.FullHelpView truncates as usual.
|
||||||
|
return chunkColumns(bindings, len(bindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderedWidth measures what FullHelpView would actually produce for
|
||||||
|
// groups, by asking it - rather than reimplementing its column and separator
|
||||||
|
// arithmetic here, which would silently drift the moment upstream changes a
|
||||||
|
// style or a separator.
|
||||||
|
//
|
||||||
|
// The width is zeroed first because that's what disables FullHelpView's own
|
||||||
|
// truncation (see its shouldAddItem): at width 0 it lays every column out in
|
||||||
|
// full, which is the untruncated width this needs to measure.
|
||||||
|
func (m Model) renderedWidth(groups [][]key.Binding) int {
|
||||||
|
unbounded := m.help
|
||||||
|
unbounded.SetWidth(0)
|
||||||
|
return lipgloss.Width(unbounded.FullHelpView(groups))
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkColumns slices bindings into consecutive groups of at most rows each.
|
||||||
|
func chunkColumns(bindings []key.Binding, rows int) [][]key.Binding {
|
||||||
|
if rows < 1 {
|
||||||
|
rows = 1
|
||||||
|
}
|
||||||
|
groups := make([][]key.Binding, 0, (len(bindings)+rows-1)/rows)
|
||||||
|
for i := 0; i < len(bindings); i += rows {
|
||||||
|
groups = append(groups, bindings[i:min(i+rows, len(bindings))])
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
@@ -1,309 +0,0 @@
|
|||||||
# layout
|
|
||||||
|
|
||||||
Arrange panes in a binary split tree (BSP, tmux/i3-style), navigate between them with
|
|
||||||
spatial `ctrl+hjkl`, and get a help bar that always reflects whatever's focused - no
|
|
||||||
matter how deep the tree is, or how many of those splits are themselves other `layout`
|
|
||||||
trees nested inside a pane.
|
|
||||||
|
|
||||||
`layout` only owns geometry, focus and routing. It draws no border and imposes no
|
|
||||||
style: every pane decides how to render itself for the size and focus state it's given.
|
|
||||||
|
|
||||||
## Concepts
|
|
||||||
|
|
||||||
Three things:
|
|
||||||
|
|
||||||
- **`Pane`** is your content: `Init() tea.Cmd`, `Update(tea.Msg) (Pane, tea.Cmd)`,
|
|
||||||
`View() string` - the same shape used by every other custom component in this repo.
|
|
||||||
- **`Node`** is the shape of the tree. `Leaf(id, pane)` is a slot holding one `Pane`,
|
|
||||||
addressed everywhere else (`SendMsg`, `RequestFocusMsg`, `SplitLeaf`, `CloseLeaf`,
|
|
||||||
`Resize`) by that `id`. `Split`/`HSplit`/`VSplit` divide space between two child
|
|
||||||
`Node`s.
|
|
||||||
- **`Model`** is the running layout: a `Node` tree plus focus, sizing and the help bar.
|
|
||||||
Build one with `layout.New(root, layout.AsRoot())`.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
root := layout.HSplit(0.3,
|
|
||||||
layout.Leaf("sidebar", newSidebarPane()),
|
|
||||||
layout.Leaf("content", newContentPane()),
|
|
||||||
)
|
|
||||||
m := layout.New(root, layout.AsRoot())
|
|
||||||
|
|
||||||
if err := layout.Run(m); err != nil {
|
|
||||||
fmt.Println("Error running program:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
That's a working two-pane app: `ctrl+h`/`ctrl+l` move focus between "sidebar" and
|
|
||||||
"content", `?` toggles the help bar, everything resizes with the terminal.
|
|
||||||
`layout.Run(m, opts ...tea.ProgramOption)` wraps `m` for `tea.NewProgram` and runs it,
|
|
||||||
alt-screen included. `layout` itself reserves no quit key - deciding how (and whether)
|
|
||||||
the app quits is the host's call, same as any other app-level policy; see the examples
|
|
||||||
for the usual `ctrl+c`/`q` pattern.
|
|
||||||
|
|
||||||
## Writing a pane
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Pane interface {
|
|
||||||
Init() tea.Cmd
|
|
||||||
Update(tea.Msg) (Pane, tea.Cmd)
|
|
||||||
View() string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`layout` tells a pane its size and focus state entirely through messages - never assume
|
|
||||||
either any other way:
|
|
||||||
|
|
||||||
- **`SizeMsg{ID, Width, Height}`** whenever the pane's allocated space changes (first
|
|
||||||
layout, terminal resize, a sibling split/close/resize...). `ID` is the pane's own id,
|
|
||||||
learned here so it can later fill `RequestFocusMsg.Source` (see below).
|
|
||||||
- **`FocusMsg{}`** / **`BlurMsg{}`** whenever the pane gains or loses keyboard focus.
|
|
||||||
Named distinctly from bubbletea's own `tea.FocusMsg`/`tea.BlurMsg`, which are about
|
|
||||||
terminal focus, not pane focus.
|
|
||||||
|
|
||||||
Two rules to actually get right:
|
|
||||||
|
|
||||||
- **`View()` must render exactly the last width/height you were told.** `layout`
|
|
||||||
composes panes side by side with `lipgloss.JoinHorizontal`/`JoinVertical` - if a pane
|
|
||||||
renders the wrong size, the whole layout visibly misaligns.
|
|
||||||
- **You own your own chrome.** `layout` never draws a border. `layout.Bordered(focused,
|
|
||||||
w, h, content)` covers the common case (border color follows focus, via
|
|
||||||
`style.S.Primary`/`style.S.Subtle`) as an optional helper - draw nothing, or draw
|
|
||||||
something else entirely, if you want.
|
|
||||||
|
|
||||||
## Building the tree
|
|
||||||
|
|
||||||
```go
|
|
||||||
root := layout.HSplit(0.3,
|
|
||||||
layout.Leaf("sidebar", newSidebarPane()),
|
|
||||||
layout.Leaf("content", newContentPane()),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `layout.Leaf(id, pane)` - one pane, addressed by `id` everywhere else in the API.
|
|
||||||
- `layout.HSplit(ratio, first, second)` / `layout.VSplit(ratio, first, second)` - divide
|
|
||||||
space horizontally (side by side) or vertically (stacked), giving `ratio` (0 to 1) to
|
|
||||||
`first` and the rest to `second`. `layout.Split(id, dir, ratio, first, second)` is the
|
|
||||||
general form if the split itself needs an `id` (see "Resizing at runtime" below).
|
|
||||||
Nest freely:
|
|
||||||
|
|
||||||
```go
|
|
||||||
root := layout.HSplit(0.25,
|
|
||||||
sidebar,
|
|
||||||
layout.VSplit(0.7,
|
|
||||||
layout.HSplit(0.5, topLeft, topRight),
|
|
||||||
bottom,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Sizing
|
|
||||||
|
|
||||||
```go
|
|
||||||
layout.HSplit(0.3, sidebar, content) // sidebar gets 30%, content the rest
|
|
||||||
layout.HSplit(0.3, sidebar, content).WithMinimum(20) // 30%, but never below 20 cells
|
|
||||||
layout.HSplit(0.3, sidebar, content).WithMaximum(40) // 30%, but never above 40 cells
|
|
||||||
layout.HSplit(0.3, sidebar, content).WithMinimum(20).WithMaximum(20) // fixed at 20 cells
|
|
||||||
```
|
|
||||||
|
|
||||||
`WithMinimum`/`WithMaximum` clamp the resolved size of the split's *first* child (in
|
|
||||||
cells: columns for a horizontal split, rows for a vertical one). Setting both to the
|
|
||||||
same value pins it regardless of ratio - the way to get an exact-width sidebar.
|
|
||||||
|
|
||||||
## Navigation and the help bar
|
|
||||||
|
|
||||||
`ctrl+h`/`ctrl+l`/`ctrl+j`/`ctrl+k` move focus spatially - whichever pane is actually
|
|
||||||
adjacent in that direction (tmux's `select-pane -L/-D/-U/-R`), not "next in the tree".
|
|
||||||
`?` toggles the help bar between its short and full form. Both work out of the box.
|
|
||||||
|
|
||||||
To customize the keys:
|
|
||||||
|
|
||||||
```go
|
|
||||||
km := layout.DefaultKeyMap()
|
|
||||||
km.FocusLeft = key.NewBinding(key.WithKeys("left"), key.WithHelp("←", "focus left"))
|
|
||||||
m := layout.New(root, layout.AsRoot(), layout.WithKeyMap(km))
|
|
||||||
```
|
|
||||||
|
|
||||||
### Making a pane show up in the help bar
|
|
||||||
|
|
||||||
Implement `HelpProvider`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type HelpProvider interface {
|
|
||||||
HelpBindings() []key.Binding
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`layout` reads it fresh every render from whichever pane is currently focused, at
|
|
||||||
whatever depth (see "Composing bigger apps" below) - nothing to push or keep in sync. A
|
|
||||||
pane that doesn't implement it just contributes nothing; the bar still shows `layout`'s
|
|
||||||
own controls (`?`, `ctrl+hjkl`), it never disappears.
|
|
||||||
|
|
||||||
**Only the outermost `Model` should render a help bar.** Pass `layout.AsRoot()` only to
|
|
||||||
the one actually handed to `Run`/`tea.NewProgram` - an embedded `Model` (see "Composing
|
|
||||||
bigger apps") built without it contributes its focused pane's bindings to the outer bar
|
|
||||||
instead of drawing a second one of its own.
|
|
||||||
|
|
||||||
## Talking between panes
|
|
||||||
|
|
||||||
A pane never holds a reference to another - it returns a `tea.Cmd` and lets `layout`
|
|
||||||
deliver it, by id, wherever that id lives in the tree (including inside a nested
|
|
||||||
`layout.Model` - see "Composing bigger apps"):
|
|
||||||
|
|
||||||
```go
|
|
||||||
// deliver an arbitrary message to another pane's Update, regardless of focus
|
|
||||||
return p, func() tea.Msg {
|
|
||||||
return layout.SendMsg{Target: "content", Msg: pageChangedMsg{page: selected}}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
An unknown `Target` is silently ignored.
|
|
||||||
|
|
||||||
### Asking layout to move focus
|
|
||||||
|
|
||||||
```go
|
|
||||||
return p, func() tea.Msg {
|
|
||||||
return layout.RequestFocusMsg{Source: p.id, Target: "content"}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`p.id` is whatever the pane last learned from `SizeMsg.ID`. **Only honored when
|
|
||||||
`Source` is the pane that currently, genuinely holds focus** - a blurred pane (say,
|
|
||||||
reacting to a `SendMsg` while in the background) can't redirect focus this way, for
|
|
||||||
itself or anyone else; only the pane actually focused right now can hand focus off to
|
|
||||||
another. An unauthorized `Source`, or an unknown `Target`, is silently ignored.
|
|
||||||
|
|
||||||
A concrete pattern: a sidebar list drives *and* jumps to a content pane, purely through
|
|
||||||
messages:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (p sidebarPane) Update(msg tea.Msg) (layout.Pane, tea.Cmd) {
|
|
||||||
prevIndex := p.list.Index()
|
|
||||||
var cmd tea.Cmd
|
|
||||||
p.list, cmd = p.list.Update(msg)
|
|
||||||
|
|
||||||
if p.list.Index() != prevIndex {
|
|
||||||
selected := p.list.SelectedItem().(page)
|
|
||||||
cmd = tea.Batch(cmd,
|
|
||||||
func() tea.Msg { return layout.SendMsg{Target: "content", Msg: pageChangedMsg{selected}} },
|
|
||||||
func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Reshaping the tree at runtime
|
|
||||||
|
|
||||||
```go
|
|
||||||
m, cmd := m.SplitLeaf("editor", layout.Vertical, "terminal", newTerminalPane())
|
|
||||||
m, cmd = m.CloseLeaf("terminal")
|
|
||||||
m, cmd = m.Resize("main-split", 0.6)
|
|
||||||
m, cmd = m.SetPane("workspace", newSecondPagePane())
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`SplitLeaf(id, dir, newID, newModel, opts ...SplitOption)`** splits the leaf `id`
|
|
||||||
into two: `id` keeps its original pane on one side, `Leaf(newID, newModel)` takes the
|
|
||||||
other, joined by a 50/50 split by default. Override with `WithSplitID`,
|
|
||||||
`WithSplitRatio`, `WithSplitMinimum`, `WithSplitMaximum`.
|
|
||||||
- **`CloseLeaf(id)`** removes a leaf; its sibling takes the place of their parent split.
|
|
||||||
Focus moves elsewhere automatically if `id` was focused. The tree's own last
|
|
||||||
remaining leaf can't be closed this way.
|
|
||||||
- **`Resize(splitID, ratio)`** changes a split's ratio. Only reachable if the split was
|
|
||||||
given an id, via `(*Node).WithID` (or `WithSplitID` when it was created by
|
|
||||||
`SplitLeaf`) - `HSplit`/`VSplit` leave it unaddressable (`""`) by default.
|
|
||||||
- **`SetPane(id, newPane)`** swaps what's rendered at an existing leaf without touching
|
|
||||||
the tree's shape - the way an app switches its content area between entirely
|
|
||||||
different pages/sub-apps, each potentially its own package, as opposed to a pane
|
|
||||||
updating its own internal state in response to a message. The new pane is `Init`'d and
|
|
||||||
immediately told its size; it's told `FocusMsg` too if `id` currently holds focus,
|
|
||||||
since whatever it's replacing never will be again.
|
|
||||||
|
|
||||||
A pane never holds a reference to the `Model` it lives in, so from inside a pane's own
|
|
||||||
`Update`, use the message forms instead - `SplitLeafMsg`, `CloseLeafMsg`, `ResizeMsg`,
|
|
||||||
`SetPaneMsg`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
return p, func() tea.Msg {
|
|
||||||
return layout.SplitLeafMsg{ID: "editor", Dir: layout.Vertical, NewID: "terminal", NewModel: newTerminalPane()}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Composing bigger apps
|
|
||||||
|
|
||||||
A `layout.Model` is itself a `Pane` (and a `Navigable`, see below) - embed one inside
|
|
||||||
another directly, no wrapper needed:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func newWorkspace() layout.Model {
|
|
||||||
root := layout.VSplit(0.7,
|
|
||||||
layout.Leaf("editor", newEditorPane()),
|
|
||||||
layout.Leaf("terminal", newTerminalPane()),
|
|
||||||
)
|
|
||||||
return layout.New(root) // no AsRoot(): the outer Model already renders one help bar
|
|
||||||
}
|
|
||||||
|
|
||||||
root := layout.HSplit(0.25,
|
|
||||||
layout.Leaf("sidebar", newSidebarPane()),
|
|
||||||
layout.Leaf("workspace", newWorkspace()),
|
|
||||||
)
|
|
||||||
m := layout.New(root, layout.AsRoot())
|
|
||||||
```
|
|
||||||
|
|
||||||
Once embedded this way, everything works transparently:
|
|
||||||
|
|
||||||
- `ctrl+hjkl` tries moving focus *inside* whatever's currently focused first; only once
|
|
||||||
that reports being at its own edge does the level above move between its own direct
|
|
||||||
children instead.
|
|
||||||
- The help bar keeps showing exactly one bar, reflecting whatever's focused anywhere in
|
|
||||||
the nesting.
|
|
||||||
- `SendMsg`/`RequestFocusMsg` reach an id inside a nested tree automatically, without
|
|
||||||
the outer tree needing to know it's there.
|
|
||||||
- The nested tree's own shape stays its own business - nothing from outside reaches
|
|
||||||
into it structurally, only messages cross that boundary.
|
|
||||||
|
|
||||||
This all works because `layout.Model` implements `Navigable`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Navigable interface {
|
|
||||||
Pane
|
|
||||||
Leaves() []LeafRect
|
|
||||||
MoveFocus(dir FocusDirection) bool
|
|
||||||
Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
|
|
||||||
Focus(id string) (handled bool, cmd tea.Cmd)
|
|
||||||
FocusedHelp() []key.Binding
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A hand-rolled `Pane` never needs to implement this itself - it's what lets one
|
|
||||||
`layout.Model` recognize *another* `layout.Model` sitting in one of its leaves and
|
|
||||||
delegate to it, at arbitrary nesting depth. You'll only reach for it directly if you're
|
|
||||||
building something that itself wants to compose with `layout` the same way `layout`
|
|
||||||
composes with itself.
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
- `examples/layout/basic` - a 2x2 grid, no custom border, the minimum to get started.
|
|
||||||
- `examples/layout/bordered` - each pane draws its own border via `layout.Bordered`,
|
|
||||||
following focus.
|
|
||||||
- `examples/layout/nested` - a whole `layout.Model` embedded as a pane, ctrl+hjkl and
|
|
||||||
the help bar both working transparently across the boundary.
|
|
||||||
- `examples/layout/messaging` - a "control" pane driving and focusing an "editor" pane
|
|
||||||
by id via `SendMsg`/`RequestFocusMsg`.
|
|
||||||
- `examples/layout/help` - the help bar changing to match whatever's focused, including
|
|
||||||
a pane that implements no bindings at all.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import "math"
|
|
||||||
|
|
||||||
// Rect is an axis-aligned screen region in terminal cells, origin top-left.
|
|
||||||
type Rect struct {
|
|
||||||
X, Y, W, H int
|
|
||||||
}
|
|
||||||
|
|
||||||
// LeafRect pairs a Leaf's id with the Rect it was allocated by the most
|
|
||||||
// recent layout pass.
|
|
||||||
type LeafRect struct {
|
|
||||||
ID string
|
|
||||||
Rect Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeLayout descends the tree rooted at n, allocating r between its
|
|
||||||
// leaves according to each Split's ratio/min/max, and returns a flat
|
|
||||||
// registry of every leaf's resolved Rect. Order is deterministic (a
|
|
||||||
// depth-first walk, first child before second), which is what makes it safe
|
|
||||||
// to use directly as a stable iteration order elsewhere (Init, routing).
|
|
||||||
func computeLayout(n *Node, r Rect) []LeafRect {
|
|
||||||
if n == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if n.leaf {
|
|
||||||
return []LeafRect{{ID: n.id, Rect: r}}
|
|
||||||
}
|
|
||||||
|
|
||||||
var firstRect, secondRect Rect
|
|
||||||
if n.dir == Horizontal {
|
|
||||||
w1 := resolveSize(n, r.W)
|
|
||||||
firstRect = Rect{X: r.X, Y: r.Y, W: w1, H: r.H}
|
|
||||||
secondRect = Rect{X: r.X + w1, Y: r.Y, W: r.W - w1, H: r.H}
|
|
||||||
} else {
|
|
||||||
h1 := resolveSize(n, r.H)
|
|
||||||
firstRect = Rect{X: r.X, Y: r.Y, W: r.W, H: h1}
|
|
||||||
secondRect = Rect{X: r.X, Y: r.Y + h1, W: r.W, H: r.H - h1}
|
|
||||||
}
|
|
||||||
|
|
||||||
leaves := computeLayout(n.first, firstRect)
|
|
||||||
return append(leaves, computeLayout(n.second, secondRect)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSize returns the cell size a Split's first child gets out of total,
|
|
||||||
// starting from n.ratio and then clamped to [n.min, n.max] (0 on either side
|
|
||||||
// means that bound is unset). n.min == n.max fixes the size outright,
|
|
||||||
// regardless of ratio.
|
|
||||||
func resolveSize(n *Node, total int) int {
|
|
||||||
size := int(math.Round(n.ratio * float64(total)))
|
|
||||||
if n.min > 0 && size < n.min {
|
|
||||||
size = n.min
|
|
||||||
}
|
|
||||||
if n.max > 0 && size > n.max {
|
|
||||||
size = n.max
|
|
||||||
}
|
|
||||||
if size < 0 {
|
|
||||||
size = 0
|
|
||||||
}
|
|
||||||
if size > total {
|
|
||||||
size = total
|
|
||||||
}
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestComputeLayoutEvenSplit(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
leaves := computeLayout(root, Rect{W: 100, H: 40})
|
|
||||||
|
|
||||||
want := map[string]Rect{
|
|
||||||
"a": {X: 0, Y: 0, W: 50, H: 40},
|
|
||||||
"b": {X: 50, Y: 0, W: 50, H: 40},
|
|
||||||
}
|
|
||||||
assertLeafRects(t, leaves, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeLayoutVerticalRatio(t *testing.T) {
|
|
||||||
root := VSplit(0.25, Leaf("top", newStub()), Leaf("bottom", newStub()))
|
|
||||||
leaves := computeLayout(root, Rect{W: 80, H: 40})
|
|
||||||
|
|
||||||
want := map[string]Rect{
|
|
||||||
"top": {X: 0, Y: 0, W: 80, H: 10},
|
|
||||||
"bottom": {X: 0, Y: 10, W: 80, H: 30},
|
|
||||||
}
|
|
||||||
assertLeafRects(t, leaves, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeLayoutMinimum(t *testing.T) {
|
|
||||||
root := HSplit(0.1, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20)
|
|
||||||
leaves := computeLayout(root, Rect{W: 100, H: 10})
|
|
||||||
|
|
||||||
want := map[string]Rect{
|
|
||||||
"a": {X: 0, Y: 0, W: 20, H: 10},
|
|
||||||
"b": {X: 20, Y: 0, W: 80, H: 10},
|
|
||||||
}
|
|
||||||
assertLeafRects(t, leaves, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeLayoutMaximum(t *testing.T) {
|
|
||||||
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMaximum(20)
|
|
||||||
leaves := computeLayout(root, Rect{W: 100, H: 10})
|
|
||||||
|
|
||||||
want := map[string]Rect{
|
|
||||||
"a": {X: 0, Y: 0, W: 20, H: 10},
|
|
||||||
"b": {X: 20, Y: 0, W: 80, H: 10},
|
|
||||||
}
|
|
||||||
assertLeafRects(t, leaves, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeLayoutFixedWhenMinEqualsMax(t *testing.T) {
|
|
||||||
root := HSplit(0.9, Leaf("a", newStub()), Leaf("b", newStub())).WithMinimum(20).WithMaximum(20)
|
|
||||||
leaves := computeLayout(root, Rect{W: 100, H: 10})
|
|
||||||
|
|
||||||
want := map[string]Rect{
|
|
||||||
"a": {X: 0, Y: 0, W: 20, H: 10},
|
|
||||||
"b": {X: 20, Y: 0, W: 80, H: 10},
|
|
||||||
}
|
|
||||||
assertLeafRects(t, leaves, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeLayoutNested(t *testing.T) {
|
|
||||||
root := HSplit(0.3,
|
|
||||||
Leaf("sidebar", newStub()),
|
|
||||||
VSplit(0.5, Leaf("top", newStub()), Leaf("bottom", newStub())),
|
|
||||||
)
|
|
||||||
leaves := computeLayout(root, Rect{W: 100, H: 20})
|
|
||||||
|
|
||||||
want := map[string]Rect{
|
|
||||||
"sidebar": {X: 0, Y: 0, W: 30, H: 20},
|
|
||||||
"top": {X: 30, Y: 0, W: 70, H: 10},
|
|
||||||
"bottom": {X: 30, Y: 10, W: 70, H: 10},
|
|
||||||
}
|
|
||||||
assertLeafRects(t, leaves, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertLeafRects(t *testing.T, leaves []LeafRect, want map[string]Rect) {
|
|
||||||
t.Helper()
|
|
||||||
if len(leaves) != len(want) {
|
|
||||||
t.Fatalf("got %d leaves, want %d (%v)", len(leaves), len(want), leaves)
|
|
||||||
}
|
|
||||||
for _, lr := range leaves {
|
|
||||||
wr, ok := want[lr.ID]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("unexpected leaf %q", lr.ID)
|
|
||||||
}
|
|
||||||
if lr.Rect != wr {
|
|
||||||
t.Errorf("leaf %q: got %+v, want %+v", lr.ID, lr.Rect, wr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-130
@@ -1,130 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HelpProvider is how a pane opts into the help bar: implement it and
|
|
||||||
// return whatever bindings you want shown while you're focused. A pane
|
|
||||||
// that doesn't implement it just contributes nothing - the help bar still
|
|
||||||
// shows layout's own controls (ctrl+hjkl, ?), it never disappears entirely.
|
|
||||||
type HelpProvider interface {
|
|
||||||
HelpBindings() []key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpBindings implements HelpProvider by delegating to FocusedHelp, so a
|
|
||||||
// Model embedded as a HelpProvider behaves identically to one consulted as
|
|
||||||
// a Navigable.
|
|
||||||
func (m Model) HelpBindings() []key.Binding {
|
|
||||||
return m.FocusedHelp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// FocusedHelp implements Navigable: the bindings for whatever pane
|
|
||||||
// currently has focus, drilling into a nested Navigable automatically until
|
|
||||||
// it reaches the real pane at the bottom. Returns nil if the focused pane
|
|
||||||
// (at any depth) implements neither Navigable nor HelpProvider.
|
|
||||||
func (m Model) FocusedHelp() []key.Binding {
|
|
||||||
focused, ok := findNode(m.root, m.state.id)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if nav, ok := focused.model.(Navigable); ok {
|
|
||||||
return nav.FocusedHelp()
|
|
||||||
}
|
|
||||||
if hp, ok := focused.model.(HelpProvider); ok {
|
|
||||||
return hp.HelpBindings()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// helpKeyMap adapts a focused pane's flat HelpProvider bindings plus
|
|
||||||
// layout's own KeyMap into the shape bubbles/help.KeyMap expects.
|
|
||||||
type helpKeyMap struct {
|
|
||||||
pane []key.Binding
|
|
||||||
own KeyMap
|
|
||||||
width int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortHelp implements help.KeyMap. own leads (its ToggleHelp is always
|
|
||||||
// first, see KeyMap.ShortHelp), the focused pane's own bindings follow.
|
|
||||||
func (h helpKeyMap) ShortHelp() []key.Binding {
|
|
||||||
return append(append([]key.Binding{}, h.own.ShortHelp()...), h.pane...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullHelp implements help.KeyMap. Rather than the fixed grouping ShortHelp
|
|
||||||
// mirrors, it flattens every binding into one ordered list - own first, so
|
|
||||||
// ToggleHelp lands in the first column's first row, then the pane's - and
|
|
||||||
// re-flows it into as many columns as fit within width, maximizing columns
|
|
||||||
// to minimize the number of rows the full help view takes.
|
|
||||||
func (h helpKeyMap) FullHelp() [][]key.Binding {
|
|
||||||
all := append(append([]key.Binding{}, flattenGroups(h.own.FullHelp())...), h.pane...)
|
|
||||||
return flowColumns(all, h.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func flattenGroups(groups [][]key.Binding) []key.Binding {
|
|
||||||
var flat []key.Binding
|
|
||||||
for _, g := range groups {
|
|
||||||
flat = append(flat, g...)
|
|
||||||
}
|
|
||||||
return flat
|
|
||||||
}
|
|
||||||
|
|
||||||
// flowColumns arranges bindings into as many columns as fit within width
|
|
||||||
// without overflowing. It fills each column top-to-bottom before moving to
|
|
||||||
// the next, which is what bubbles/help.FullHelpView expects: one inner
|
|
||||||
// slice per column, rendered as a vertical stack.
|
|
||||||
func flowColumns(bindings []key.Binding, width int) [][]key.Binding {
|
|
||||||
enabled := make([]key.Binding, 0, len(bindings))
|
|
||||||
for _, kb := range bindings {
|
|
||||||
if kb.Enabled() {
|
|
||||||
enabled = append(enabled, kb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(enabled) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if width <= 0 {
|
|
||||||
return [][]key.Binding{enabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
for rows := 1; rows <= len(enabled); rows++ {
|
|
||||||
groups := chunkRows(enabled, rows)
|
|
||||||
if columnsWidth(groups) <= width {
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [][]key.Binding{enabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
// chunkRows splits bindings into groups of at most rows items each, filling
|
|
||||||
// each group before moving to the next - the column-major order help's
|
|
||||||
// FullHelpView renders (first group is the leftmost column).
|
|
||||||
func chunkRows(bindings []key.Binding, rows int) [][]key.Binding {
|
|
||||||
var groups [][]key.Binding
|
|
||||||
for i := 0; i < len(bindings); i += rows {
|
|
||||||
end := min(i+rows, len(bindings))
|
|
||||||
groups = append(groups, bindings[i:end])
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
// columnsWidth mirrors bubbles/help.FullHelpView's own width accounting:
|
|
||||||
// each column is as wide as its longest key plus a space plus its longest
|
|
||||||
// description, columns separated by FullSeparator's width (4 cells, " ").
|
|
||||||
func columnsWidth(groups [][]key.Binding) int {
|
|
||||||
const separatorWidth = 4
|
|
||||||
total := 0
|
|
||||||
for i, group := range groups {
|
|
||||||
if i > 0 {
|
|
||||||
total += separatorWidth
|
|
||||||
}
|
|
||||||
var keyWidth, descWidth int
|
|
||||||
for _, kb := range group {
|
|
||||||
keyWidth = max(keyWidth, lipgloss.Width(kb.Help().Key))
|
|
||||||
descWidth = max(descWidth, lipgloss.Width(kb.Help().Desc))
|
|
||||||
}
|
|
||||||
total += keyWidth + 1 + descWidth
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stubPane is a minimal Pane used across the test suite: it records every
|
|
||||||
// message it receives (and counts Focus/Blur specifically) and can be told
|
|
||||||
// to return a fixed cmd on its next Update, so tests can assert on both
|
|
||||||
// sides of the layout <-> pane contract without a real component.
|
|
||||||
type stubPane struct {
|
|
||||||
focusN, blurN int
|
|
||||||
w, h int
|
|
||||||
msgs []tea.Msg
|
|
||||||
nextCmd tea.Cmd
|
|
||||||
help []key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStub() *stubPane { return &stubPane{} }
|
|
||||||
|
|
||||||
func (p *stubPane) Init() tea.Cmd { return nil }
|
|
||||||
|
|
||||||
func (p *stubPane) Update(msg tea.Msg) (Pane, tea.Cmd) {
|
|
||||||
p.msgs = append(p.msgs, msg)
|
|
||||||
switch m := msg.(type) {
|
|
||||||
case FocusMsg:
|
|
||||||
p.focusN++
|
|
||||||
case BlurMsg:
|
|
||||||
p.blurN++
|
|
||||||
case SizeMsg:
|
|
||||||
p.w, p.h = m.Width, m.Height
|
|
||||||
}
|
|
||||||
cmd := p.nextCmd
|
|
||||||
p.nextCmd = nil
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *stubPane) View() string { return "" }
|
|
||||||
|
|
||||||
// HelpBindings implements HelpProvider.
|
|
||||||
func (p *stubPane) HelpBindings() []key.Binding { return p.help }
|
|
||||||
|
|
||||||
func (p *stubPane) last() tea.Msg {
|
|
||||||
if len(p.msgs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return p.msgs[len(p.msgs)-1]
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import "charm.land/bubbles/v2/key"
|
|
||||||
|
|
||||||
// KeyMap holds the bindings Model itself reacts to: directional focus
|
|
||||||
// movement and toggling the help bar. A pane's own bindings are separate
|
|
||||||
// (see HelpProvider) - these are only the ones layout intercepts before a
|
|
||||||
// key ever reaches the focused pane.
|
|
||||||
type KeyMap struct {
|
|
||||||
FocusLeft key.Binding
|
|
||||||
FocusRight key.Binding
|
|
||||||
FocusUp key.Binding
|
|
||||||
FocusDown key.Binding
|
|
||||||
ToggleHelp key.Binding
|
|
||||||
|
|
||||||
// ShowFocusInShortHelp also lists ctrl+hjkl on the short help line, not
|
|
||||||
// just the full one. Off by default (see DefaultKeyMap): the four
|
|
||||||
// bindings crowd a single line for little gain, since they're always
|
|
||||||
// one '?' away in the full view regardless. Set it on a KeyMap passed
|
|
||||||
// to WithKeyMap to opt back in.
|
|
||||||
ShowFocusInShortHelp bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultKeyMap returns the standard tmux/vim-style bindings: ctrl+h/j/k/l
|
|
||||||
// to move focus, ? to toggle the help bar.
|
|
||||||
func DefaultKeyMap() KeyMap {
|
|
||||||
return KeyMap{
|
|
||||||
FocusLeft: key.NewBinding(
|
|
||||||
key.WithKeys("ctrl+h"),
|
|
||||||
key.WithHelp("ctrl+h", "focus left"),
|
|
||||||
),
|
|
||||||
FocusRight: key.NewBinding(
|
|
||||||
key.WithKeys("ctrl+l"),
|
|
||||||
key.WithHelp("ctrl+l", "focus right"),
|
|
||||||
),
|
|
||||||
FocusUp: key.NewBinding(
|
|
||||||
key.WithKeys("ctrl+k"),
|
|
||||||
key.WithHelp("ctrl+k", "focus up"),
|
|
||||||
),
|
|
||||||
FocusDown: key.NewBinding(
|
|
||||||
key.WithKeys("ctrl+j"),
|
|
||||||
key.WithHelp("ctrl+j", "focus down"),
|
|
||||||
),
|
|
||||||
ToggleHelp: key.NewBinding(
|
|
||||||
key.WithKeys("?"),
|
|
||||||
key.WithHelp("?", "toggle help"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// focusBindings returns the four directional bindings in reading order.
|
|
||||||
func (k KeyMap) focusBindings() []key.Binding {
|
|
||||||
return []key.Binding{k.FocusLeft, k.FocusDown, k.FocusUp, k.FocusRight}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShortHelp implements help.KeyMap so KeyMap can be fed to bubbles/help
|
|
||||||
// directly for layout's own controls. ToggleHelp always leads - see
|
|
||||||
// helpKeyMap.ShortHelp, which relies on that to put '?' first in the
|
|
||||||
// composed bar too.
|
|
||||||
func (k KeyMap) ShortHelp() []key.Binding {
|
|
||||||
bindings := []key.Binding{k.ToggleHelp}
|
|
||||||
if k.ShowFocusInShortHelp {
|
|
||||||
bindings = append(bindings, k.focusBindings()...)
|
|
||||||
}
|
|
||||||
return bindings
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullHelp implements help.KeyMap. ToggleHelp always leads, same reasoning
|
|
||||||
// as ShortHelp; the focus bindings show here unconditionally, regardless of
|
|
||||||
// ShowFocusInShortHelp.
|
|
||||||
func (k KeyMap) FullHelp() [][]key.Binding {
|
|
||||||
return [][]key.Binding{
|
|
||||||
{k.ToggleHelp},
|
|
||||||
{k.FocusLeft, k.FocusRight},
|
|
||||||
{k.FocusUp, k.FocusDown},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,542 +0,0 @@
|
|||||||
// Package layout arranges Pane content in a binary split tree (BSP,
|
|
||||||
// tmux/i3-style), with spatial ctrl+hjkl focus navigation, message routing
|
|
||||||
// between panes by id, and a help bar that always reflects whatever pane is
|
|
||||||
// currently focused, however deep it's nested. It owns geometry, focus and
|
|
||||||
// routing only - it draws no border and imposes no style: each pane decides
|
|
||||||
// how to render itself for the size and focus state it's given (see SizeMsg,
|
|
||||||
// FocusMsg, BlurMsg).
|
|
||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/help"
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/anotherhadi/ilovetui/bubbles"
|
|
||||||
"github.com/anotherhadi/ilovetui/style"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pane is a leaf's content. It's the same Init/Update/View shape used by
|
|
||||||
// every other custom component in this repo (see tabs.Tab): distinct from
|
|
||||||
// the real tea.Model, whose View returns tea.View rather than string -
|
|
||||||
// that's the top level's job (see Run), not a nested pane's.
|
|
||||||
type Pane interface {
|
|
||||||
Init() tea.Cmd
|
|
||||||
Update(tea.Msg) (Pane, tea.Cmd)
|
|
||||||
View() string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigable is what makes a Model composable: embed one layout.Model inside
|
|
||||||
// another (Leaf(id, innerModel)) and it works transparently, because
|
|
||||||
// layout.Model itself implements Navigable. ctrl+hjkl first tries
|
|
||||||
// MoveFocus on whatever's currently focused; SendMsg/RequestFocusMsg reach
|
|
||||||
// into nested trees via Route/Focus; the help bar drills in via
|
|
||||||
// FocusedHelp. A pane that isn't itself a layout.Model just doesn't
|
|
||||||
// implement this, and is treated as an ordinary leaf everywhere.
|
|
||||||
type Navigable interface {
|
|
||||||
Pane
|
|
||||||
Leaves() []LeafRect
|
|
||||||
MoveFocus(dir FocusDirection) bool
|
|
||||||
Route(target string, msg tea.Msg) (handled bool, cmd tea.Cmd)
|
|
||||||
Focus(id string) (handled bool, cmd tea.Cmd)
|
|
||||||
FocusedHelp() []key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
// focusState holds the pieces of Model's state that Navigable's MoveFocus
|
|
||||||
// and Focus must be able to mutate despite having value receivers - a
|
|
||||||
// requirement of Model being usable by value as a Leaf's tea.Model and
|
|
||||||
// still satisfying Navigable when type-asserted back out of that interface.
|
|
||||||
// Boxed behind a pointer so the mutation persists across every copy of
|
|
||||||
// Model that shares it.
|
|
||||||
type focusState struct {
|
|
||||||
id string
|
|
||||||
// pendingCmd queues cmds produced by BlurMsg/FocusMsg dispatch that
|
|
||||||
// happened inside MoveFocus, which - being bool-only, per Navigable -
|
|
||||||
// has no return path for them. Drained by the nearest Update that
|
|
||||||
// actually returns a tea.Cmd; delivery lags by at most one Update
|
|
||||||
// cycle, never user-visible in practice.
|
|
||||||
pendingCmd tea.Cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model is a running layout: a Node tree, focus, sizing, and (if AsRoot)
|
|
||||||
// the help bar. Build one with New.
|
|
||||||
type Model struct {
|
|
||||||
root *Node
|
|
||||||
state *focusState
|
|
||||||
|
|
||||||
leaves []LeafRect
|
|
||||||
|
|
||||||
width, height int
|
|
||||||
|
|
||||||
keyMap KeyMap
|
|
||||||
help help.Model
|
|
||||||
showHelp bool
|
|
||||||
asRoot bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option configures a Model at construction. See AsRoot, WithKeyMap.
|
|
||||||
type Option func(*Model)
|
|
||||||
|
|
||||||
// AsRoot marks this Model as the outermost one: only a root Model renders
|
|
||||||
// its own help bar in View. Off by default, so an embedded Model (see
|
|
||||||
// Navigable) never shows a duplicate bar - only pass this to the Model
|
|
||||||
// actually handed to Run/tea.NewProgram.
|
|
||||||
func AsRoot() Option {
|
|
||||||
return func(m *Model) { m.asRoot = true }
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithKeyMap overrides the default ctrl+hjkl/? bindings.
|
|
||||||
func WithKeyMap(k KeyMap) Option {
|
|
||||||
return func(m *Model) { m.keyMap = k }
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a Model from root. The first leaf (depth-first, first child
|
|
||||||
// before second) starts focused.
|
|
||||||
func New(root *Node, opts ...Option) Model {
|
|
||||||
m := Model{
|
|
||||||
root: root,
|
|
||||||
state: &focusState{id: firstLeafID(root)},
|
|
||||||
keyMap: DefaultKeyMap(),
|
|
||||||
help: bubbles.NewHelp(),
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(&m)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// program adapts a Model (a Pane, like any other layout leaf content) into
|
|
||||||
// a real tea.Model for tea.NewProgram: the only place a Model's View needs
|
|
||||||
// to become a tea.View instead of a string (see Pane's doc comment).
|
|
||||||
type program struct{ m Model }
|
|
||||||
|
|
||||||
func (p program) Init() tea.Cmd { return p.m.Init() }
|
|
||||||
|
|
||||||
func (p program) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
updated, cmd := p.m.Update(msg)
|
|
||||||
p.m = updated.(Model)
|
|
||||||
return p, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p program) View() tea.View {
|
|
||||||
view := tea.NewView(p.m.View())
|
|
||||||
view.AltScreen = true
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run builds and starts a tea.Program for m (which should have been
|
|
||||||
// constructed with AsRoot). A convenience for the common standalone-binary
|
|
||||||
// case; an app assembling layout into a bigger tea.Program of its own can
|
|
||||||
// wrap m the same way program does above instead.
|
|
||||||
func Run(m Model, opts ...tea.ProgramOption) error {
|
|
||||||
_, err := tea.NewProgram(program{m: m}, opts...).Run()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstLeafID(n *Node) string {
|
|
||||||
for n != nil && !n.leaf {
|
|
||||||
n = n.first
|
|
||||||
}
|
|
||||||
if n == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return n.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// walk visits every leaf in the tree rooted at n, depth-first, first child
|
|
||||||
// before second - the same order computeLayout produces, so it's safe to
|
|
||||||
// rely on for anything that should stay in step with the flat registry.
|
|
||||||
func walk(n *Node, fn func(*Node)) {
|
|
||||||
if n == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if n.leaf {
|
|
||||||
fn(n)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
walk(n.first, fn)
|
|
||||||
walk(n.second, fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
walk(m.root, func(n *Node) {
|
|
||||||
if cmd := n.model.Init(); cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Only the actual root originates the initial FocusMsg. An embedded
|
|
||||||
// Model's own state.id already defaults to its first leaf (see New),
|
|
||||||
// but it must stay quiet about it until its parent actually focuses the
|
|
||||||
// leaf hosting it - which happens naturally through the ordinary
|
|
||||||
// FocusMsg/BlurMsg case in Update, cascading down as deep as needed.
|
|
||||||
// Without this guard, every nested Model fires its own initial
|
|
||||||
// FocusMsg independently, so a leaf that isn't even the outer tree's
|
|
||||||
// initial focus still shows as focused until the first real move.
|
|
||||||
if m.asRoot {
|
|
||||||
if n, ok := findNode(m.root, m.state.id); ok {
|
|
||||||
updated, cmd := n.model.Update(FocusMsg{})
|
|
||||||
n.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (Pane, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width, m.height = msg.Width, msg.Height
|
|
||||||
return m.resize()
|
|
||||||
|
|
||||||
case SizeMsg:
|
|
||||||
// A nested Model receiving its own allocation from a parent
|
|
||||||
// layout.Model - equivalent to tea.WindowSizeMsg at the root.
|
|
||||||
m.width, m.height = msg.Width, msg.Height
|
|
||||||
return m.resize()
|
|
||||||
|
|
||||||
case FocusMsg, BlurMsg:
|
|
||||||
// This whole (sub)tree just gained/lost focus at the parent's
|
|
||||||
// level: redistribute to whichever of our own leaves is focused,
|
|
||||||
// not to the tree "globally" (see Navigable doc).
|
|
||||||
return m, m.deliverToFocused(msg)
|
|
||||||
|
|
||||||
case SendMsg:
|
|
||||||
_, cmd := m.Route(msg.Target, msg.Msg)
|
|
||||||
return m, cmd
|
|
||||||
|
|
||||||
case RequestFocusMsg:
|
|
||||||
return m, m.handleRequestFocus(msg)
|
|
||||||
|
|
||||||
case SetPaneMsg:
|
|
||||||
return m.SetPane(msg.ID, msg.NewPane)
|
|
||||||
|
|
||||||
case SplitLeafMsg:
|
|
||||||
return m.SplitLeaf(msg.ID, msg.Dir, msg.NewID, msg.NewModel, msg.Opts...)
|
|
||||||
|
|
||||||
case CloseLeafMsg:
|
|
||||||
return m.CloseLeaf(msg.ID)
|
|
||||||
|
|
||||||
case ResizeMsg:
|
|
||||||
return m.Resize(msg.SplitID, msg.Ratio)
|
|
||||||
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
switch {
|
|
||||||
case key.Matches(msg, m.keyMap.ToggleHelp):
|
|
||||||
m.showHelp = !m.showHelp
|
|
||||||
m.help.ShowAll = m.showHelp
|
|
||||||
return m.resize()
|
|
||||||
case key.Matches(msg, m.keyMap.FocusLeft):
|
|
||||||
m.MoveFocus(FocusLeft)
|
|
||||||
return m, m.drainCmd()
|
|
||||||
case key.Matches(msg, m.keyMap.FocusRight):
|
|
||||||
m.MoveFocus(FocusRight)
|
|
||||||
return m, m.drainCmd()
|
|
||||||
case key.Matches(msg, m.keyMap.FocusUp):
|
|
||||||
m.MoveFocus(FocusUp)
|
|
||||||
return m, m.drainCmd()
|
|
||||||
case key.Matches(msg, m.keyMap.FocusDown):
|
|
||||||
m.MoveFocus(FocusDown)
|
|
||||||
return m, m.drainCmd()
|
|
||||||
default:
|
|
||||||
return m, m.deliverToFocused(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return m, m.broadcast(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) drainCmd() tea.Cmd {
|
|
||||||
cmd := m.state.pendingCmd
|
|
||||||
m.state.pendingCmd = nil
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliverToFocused sends msg to the currently focused leaf's model only.
|
|
||||||
// Used for ordinary key presses (only the focused pane should react to
|
|
||||||
// keyboard input) and for relaying FocusMsg/BlurMsg into a nested subtree.
|
|
||||||
func (m Model) deliverToFocused(msg tea.Msg) tea.Cmd {
|
|
||||||
n, ok := findNode(m.root, m.state.id)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
updated, cmd := n.model.Update(msg)
|
|
||||||
n.model = updated
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// broadcast sends msg to every leaf's model, focused or not - the default
|
|
||||||
// for anything that isn't one of layout's own reserved message types or a
|
|
||||||
// key press, so a blurred pane can still receive its own async messages
|
|
||||||
// (a tick driving a spinner, an HTTP response, ...).
|
|
||||||
func (m Model) broadcast(msg tea.Msg) tea.Cmd {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
walk(m.root, func(n *Node) {
|
|
||||||
updated, cmd := n.model.Update(msg)
|
|
||||||
n.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setFocus moves focus to id (assumed already validated as an existing
|
|
||||||
// leaf), dispatching BlurMsg to the old focus and FocusMsg to the new one,
|
|
||||||
// and returns the resulting batched cmd. A no-op (nil cmd) if id is already
|
|
||||||
// focused.
|
|
||||||
func (m Model) setFocus(id string) tea.Cmd {
|
|
||||||
if id == m.state.id {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
if old, ok := findNode(m.root, m.state.id); ok {
|
|
||||||
updated, cmd := old.model.Update(BlurMsg{})
|
|
||||||
old.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.state.id = id
|
|
||||||
if n, ok := findNode(m.root, id); ok {
|
|
||||||
updated, cmd := n.model.Update(FocusMsg{})
|
|
||||||
n.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitLeaf splits the leaf identified by id into two: id keeps its
|
|
||||||
// original pane on one side, a new Leaf(newID, newModel) takes the other,
|
|
||||||
// joined by a 50/50 Split (override via WithSplitRatio, WithSplitID,
|
|
||||||
// WithSplitMinimum, WithSplitMaximum). A no-op (m unchanged, nil cmd) if id
|
|
||||||
// doesn't identify an existing leaf.
|
|
||||||
func (m Model) SplitLeaf(id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (Model, tea.Cmd) {
|
|
||||||
newRoot, ok := splitLeaf(m.root, id, dir, newID, newModel, opts...)
|
|
||||||
if !ok {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.root = newRoot
|
|
||||||
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
if cmd := newModel.Init(); cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
resized, cmd := m.resize()
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
return resized, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPane replaces the Pane at leaf id with newPane, keeping its place and
|
|
||||||
// shape in the tree unchanged - unlike SplitLeaf/CloseLeaf, which reshape
|
|
||||||
// the tree, this only swaps what's rendered at an existing slot (the way an
|
|
||||||
// app switches its content area between entirely different sub-apps/pages,
|
|
||||||
// each its own package). newPane is Init'd and immediately told its size
|
|
||||||
// via SizeMsg (using id's current Rect, which by definition hasn't changed);
|
|
||||||
// it's also told FocusMsg if id currently holds focus, since the pane it's
|
|
||||||
// replacing never will. A no-op if id doesn't identify an existing leaf.
|
|
||||||
func (m Model) SetPane(id string, newPane Pane) (Model, tea.Cmd) {
|
|
||||||
n, ok := findNode(m.root, id)
|
|
||||||
if !ok || !n.leaf {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
n.model = newPane
|
|
||||||
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
if cmd := newPane.Init(); cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
if lr, ok := m.leafRect(id); ok {
|
|
||||||
updated, cmd := n.model.Update(SizeMsg{ID: id, Width: lr.Rect.W, Height: lr.Rect.H})
|
|
||||||
n.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.state.id == id {
|
|
||||||
updated, cmd := n.model.Update(FocusMsg{})
|
|
||||||
n.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseLeaf removes the leaf identified by id, promoting its sibling to take
|
|
||||||
// the place of their parent Split. If id currently has focus, focus moves to
|
|
||||||
// the tree's new first leaf. A no-op if id is the tree's own root (the last
|
|
||||||
// remaining pane can't be closed this way) or doesn't exist.
|
|
||||||
func (m Model) CloseLeaf(id string) (Model, tea.Cmd) {
|
|
||||||
newRoot, ok := closeLeaf(m.root, id)
|
|
||||||
if !ok {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.root = newRoot
|
|
||||||
|
|
||||||
var focusCmd tea.Cmd
|
|
||||||
if m.state.id == id {
|
|
||||||
focusCmd = m.setFocus(firstLeafID(m.root))
|
|
||||||
}
|
|
||||||
|
|
||||||
resized, resizeCmd := m.resize()
|
|
||||||
return resized, tea.Batch(focusCmd, resizeCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize sets the ratio of the first child of the Split identified by
|
|
||||||
// splitID (only reachable if it was given one, via (*Node).WithID or
|
|
||||||
// WithSplitID). A no-op if splitID isn't found or identifies a Leaf.
|
|
||||||
func (m Model) Resize(splitID string, ratio float64) (Model, tea.Cmd) {
|
|
||||||
n, ok := findNode(m.root, splitID)
|
|
||||||
if !ok || n.leaf {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
n.ratio = ratio
|
|
||||||
return m.resize()
|
|
||||||
}
|
|
||||||
|
|
||||||
// resize recomputes the flat leaf registry from the current root/width/
|
|
||||||
// height and dispatches SizeMsg to every leaf whose Rect actually changed
|
|
||||||
// (not just the ones directly touched by whatever triggered this - a
|
|
||||||
// sibling's size can shift too). Shared by every path that can change
|
|
||||||
// geometry: tea.WindowSizeMsg, SizeMsg (nested), SplitLeaf, CloseLeaf,
|
|
||||||
// Resize, and toggling the help bar (which changes how much height the tree
|
|
||||||
// itself gets).
|
|
||||||
func (m Model) resize() (Model, tea.Cmd) {
|
|
||||||
if m.width <= 0 || m.height <= 0 {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.help.SetWidth(m.width)
|
|
||||||
|
|
||||||
rect := m.treeRect()
|
|
||||||
newLeaves := computeLayout(m.root, rect)
|
|
||||||
|
|
||||||
old := make(map[string]Rect, len(m.leaves))
|
|
||||||
for _, lr := range m.leaves {
|
|
||||||
old[lr.ID] = lr.Rect
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
for _, lr := range newLeaves {
|
|
||||||
if prev, ok := old[lr.ID]; ok && prev == lr.Rect {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n, ok := findNode(m.root, lr.ID); ok {
|
|
||||||
updated, cmd := n.model.Update(SizeMsg{ID: lr.ID, Width: lr.Rect.W, Height: lr.Rect.H})
|
|
||||||
n.model = updated
|
|
||||||
if cmd != nil {
|
|
||||||
cmds = append(cmds, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.leaves = newLeaves
|
|
||||||
|
|
||||||
return m, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// treeRect is the region left for the split tree once the help bar (if
|
|
||||||
// AsRoot) has taken its share of the height. resize and View both go
|
|
||||||
// through this so they can never disagree about where the tree ends and
|
|
||||||
// the help bar begins.
|
|
||||||
func (m Model) treeRect() Rect {
|
|
||||||
h := m.height - m.helpHeight()
|
|
||||||
if h < 0 {
|
|
||||||
h = 0
|
|
||||||
}
|
|
||||||
return Rect{W: m.width, H: h}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) helpHeight() int {
|
|
||||||
if !m.asRoot {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if rendered := m.renderHelp(); rendered != "" {
|
|
||||||
return lipgloss.Height(rendered)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderHelp() string {
|
|
||||||
return m.help.View(helpKeyMap{pane: m.FocusedHelp(), own: m.keyMap, width: m.width})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leaves implements Navigable.
|
|
||||||
func (m Model) Leaves() []LeafRect {
|
|
||||||
return m.leaves
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) View() string {
|
|
||||||
if m.width <= 0 || m.height <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
rect := m.treeRect()
|
|
||||||
tree := renderNode(m.root, rect.W, rect.H)
|
|
||||||
|
|
||||||
if !m.asRoot {
|
|
||||||
return tree
|
|
||||||
}
|
|
||||||
help := m.renderHelp()
|
|
||||||
if help == "" {
|
|
||||||
return tree
|
|
||||||
}
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, tree, help)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderNode mirrors computeLayout's own allocation (same resolveSize calls
|
|
||||||
// on the same w/h at each level), so what's rendered here always matches
|
|
||||||
// the SizeMsg values leaves were already told via resize.
|
|
||||||
func renderNode(n *Node, w, h int) string {
|
|
||||||
if n.leaf {
|
|
||||||
// A misbehaving Pane that renders wider/taller than the SizeMsg it
|
|
||||||
// was given would otherwise desync every ancestor Join*, so clip it
|
|
||||||
// here rather than trusting the contract to hold. MaxWidth/MaxHeight
|
|
||||||
// truncate via ansi.Truncate internally, so this stays escape-code
|
|
||||||
// safe instead of mangling a Pane's own styling mid-sequence.
|
|
||||||
return lipgloss.NewStyle().MaxWidth(w).MaxHeight(h).Render(n.model.View())
|
|
||||||
}
|
|
||||||
if n.dir == Horizontal {
|
|
||||||
w1 := resolveSize(n, w)
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
|
||||||
renderNode(n.first, w1, h),
|
|
||||||
renderNode(n.second, w-w1, h),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
h1 := resolveSize(n, h)
|
|
||||||
return lipgloss.JoinVertical(lipgloss.Left,
|
|
||||||
renderNode(n.first, w, h1),
|
|
||||||
renderNode(n.second, w, h-h1),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bordered is an optional helper for panes that want the common look: a
|
|
||||||
// border that follows focus (style.S.Primary focused, style.S.Subtle
|
|
||||||
// blurred) drawn with the configured BorderType. Not required - a pane
|
|
||||||
// that wants something else, or nothing, just doesn't call this. Renders
|
|
||||||
// to exactly w by h, border included, as View() must (see FocusMsg/BlurMsg
|
|
||||||
// and SizeMsg docs).
|
|
||||||
func Bordered(focused bool, w, h int, content string) string {
|
|
||||||
color := style.S.Subtle
|
|
||||||
if focused {
|
|
||||||
color = style.S.Primary
|
|
||||||
}
|
|
||||||
// lipgloss's Width/Height already count the border as part of the box
|
|
||||||
// (they subtract its size internally before sizing the content), so w
|
|
||||||
// and h go straight through - no manual -2 here, or the box comes out
|
|
||||||
// two cells smaller than asked in both dimensions.
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Border(style.S.BorderType).
|
|
||||||
BorderForeground(color).
|
|
||||||
Width(max(w, 0)).
|
|
||||||
Height(max(h, 0)).
|
|
||||||
Render(content)
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestModel(t *testing.T, root *Node) (Model, map[string]*stubPane) {
|
|
||||||
t.Helper()
|
|
||||||
panes := map[string]*stubPane{}
|
|
||||||
walk(root, func(n *Node) {
|
|
||||||
if s, ok := n.model.(*stubPane); ok {
|
|
||||||
panes[n.id] = s
|
|
||||||
}
|
|
||||||
})
|
|
||||||
m := New(root, AsRoot())
|
|
||||||
m.Init()
|
|
||||||
return m, panes
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInitFocusesFirstLeaf(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
|
|
||||||
if m.state.id != "a" {
|
|
||||||
t.Fatalf("focusedID = %q, want %q", m.state.id, "a")
|
|
||||||
}
|
|
||||||
if panes["a"].focusN != 1 {
|
|
||||||
t.Fatalf("a.focusN = %d, want 1", panes["a"].focusN)
|
|
||||||
}
|
|
||||||
if panes["b"].focusN != 0 {
|
|
||||||
t.Fatalf("b.focusN = %d, want 0", panes["b"].focusN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWindowSizeDispatchesSizeMsg(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
// newTestModel builds with AsRoot(), so the tree gets 40 minus the
|
|
||||||
// 1-row help bar - see treeRect.
|
|
||||||
if panes["a"].w != 50 || panes["a"].h != 39 {
|
|
||||||
t.Fatalf("a size = %dx%d, want 50x39", panes["a"].w, panes["a"].h)
|
|
||||||
}
|
|
||||||
if panes["b"].w != 50 || panes["b"].h != 39 {
|
|
||||||
t.Fatalf("b size = %dx%d, want 50x39", panes["b"].w, panes["b"].h)
|
|
||||||
}
|
|
||||||
if got := len(m.Leaves()); got != 2 {
|
|
||||||
t.Fatalf("len(Leaves()) = %d, want 2", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCtrlLMovesFocusAndDispatchesBlurFocus(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
updated, _ = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl, Text: ""})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
if m.state.id != "b" {
|
|
||||||
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
|
|
||||||
}
|
|
||||||
if panes["a"].blurN != 1 {
|
|
||||||
t.Fatalf("a.blurN = %d, want 1", panes["a"].blurN)
|
|
||||||
}
|
|
||||||
if panes["b"].focusN != 1 {
|
|
||||||
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeyPressOnlyReachesFocusedPane(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
updated, _ = m.Update(tea.KeyPressMsg{Text: "x"})
|
|
||||||
_ = updated.(Model)
|
|
||||||
|
|
||||||
if _, ok := panes["a"].last().(tea.KeyPressMsg); !ok {
|
|
||||||
t.Fatalf("focused pane a should have received the key press, got %#v", panes["a"].last())
|
|
||||||
}
|
|
||||||
if _, ok := panes["b"].last().(tea.KeyPressMsg); ok {
|
|
||||||
t.Fatalf("blurred pane b should not have received the key press")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitLeafAddsAndSizesNewPane(t *testing.T) {
|
|
||||||
root := Leaf("a", newStub())
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
newPane := newStub()
|
|
||||||
m, _ = m.SplitLeaf("a", Horizontal, "b", newPane)
|
|
||||||
|
|
||||||
if got := len(m.Leaves()); got != 2 {
|
|
||||||
t.Fatalf("len(Leaves()) = %d, want 2", got)
|
|
||||||
}
|
|
||||||
if newPane.w == 0 || newPane.h == 0 {
|
|
||||||
t.Fatalf("new pane never received a SizeMsg: w=%d h=%d", newPane.w, newPane.h)
|
|
||||||
}
|
|
||||||
if panes["a"].w != 50 {
|
|
||||||
t.Fatalf("a.w = %d, want 50 after 50/50 split", panes["a"].w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetPaneSwapsContentKeepingShape(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
// "a" is focused (first leaf).
|
|
||||||
|
|
||||||
replacement := newStub()
|
|
||||||
m, _ = m.SetPane("a", replacement)
|
|
||||||
|
|
||||||
if got := len(m.Leaves()); got != 2 {
|
|
||||||
t.Fatalf("len(Leaves()) = %d, want 2 (SetPane must not reshape the tree)", got)
|
|
||||||
}
|
|
||||||
if replacement.w != 50 || replacement.h != 39 {
|
|
||||||
t.Fatalf("replacement size = %dx%d, want 50x39 (a's existing Rect)", replacement.w, replacement.h)
|
|
||||||
}
|
|
||||||
if replacement.focusN != 1 {
|
|
||||||
t.Fatalf("replacement.focusN = %d, want 1: \"a\" currently holds focus", replacement.focusN)
|
|
||||||
}
|
|
||||||
if panes["b"].w != 50 {
|
|
||||||
t.Fatalf("b.w = %d, want unchanged 50", panes["b"].w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetPaneOnBlurredLeafDoesNotFocus(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, _ := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
// "b" is blurred (only "a" is focused initially).
|
|
||||||
|
|
||||||
replacement := newStub()
|
|
||||||
m, _ = m.SetPane("b", replacement)
|
|
||||||
|
|
||||||
if replacement.focusN != 0 {
|
|
||||||
t.Fatalf("replacement.focusN = %d, want 0: \"b\" isn't focused", replacement.focusN)
|
|
||||||
}
|
|
||||||
if replacement.w != 50 || replacement.h != 39 {
|
|
||||||
t.Fatalf("replacement size = %dx%d, want 50x39", replacement.w, replacement.h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetPaneUnknownIDIsNoop(t *testing.T) {
|
|
||||||
root := Leaf("a", newStub())
|
|
||||||
m, _ := newTestModel(t, root)
|
|
||||||
|
|
||||||
m2, cmd := m.SetPane("nope", newStub())
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatalf("expected nil cmd for an unknown SetPane id, got %v", cmd)
|
|
||||||
}
|
|
||||||
if len(m2.Leaves()) != len(m.Leaves()) {
|
|
||||||
t.Fatalf("tree changed after a no-op SetPane")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCloseLeafReassignsFocus(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, _ := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
// focus is on "a"; closing it must move focus to what remains.
|
|
||||||
|
|
||||||
m, _ = m.CloseLeaf("a")
|
|
||||||
|
|
||||||
if got := len(m.Leaves()); got != 1 {
|
|
||||||
t.Fatalf("len(Leaves()) = %d, want 1", got)
|
|
||||||
}
|
|
||||||
if m.state.id != "b" {
|
|
||||||
t.Fatalf("focusedID = %q, want %q after closing the focused leaf", m.state.id, "b")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCloseLeafRootIsNoop(t *testing.T) {
|
|
||||||
root := Leaf("only", newStub())
|
|
||||||
m, _ := newTestModel(t, root)
|
|
||||||
|
|
||||||
m2, cmd := m.CloseLeaf("only")
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatalf("expected nil cmd closing the tree's only leaf, got %v", cmd)
|
|
||||||
}
|
|
||||||
if got := len(m2.Leaves()); got != len(m.Leaves()) {
|
|
||||||
t.Fatalf("tree changed after a no-op close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResizeChangesRatio(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub())).WithID("split")
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
m, _ = m.Resize("split", 0.8)
|
|
||||||
|
|
||||||
if panes["a"].w != 80 {
|
|
||||||
t.Fatalf("a.w = %d, want 80 after Resize to 0.8", panes["a"].w)
|
|
||||||
}
|
|
||||||
if panes["b"].w != 20 {
|
|
||||||
t.Fatalf("b.w = %d, want 20 after Resize to 0.8", panes["b"].w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMsgDeliversToTarget(t *testing.T) {
|
|
||||||
type payload struct{ n int }
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
// a is focused via Init's initial FocusMsg; b never received anything yet.
|
|
||||||
|
|
||||||
updated, _ := m.Update(SendMsg{Target: "b", Msg: payload{n: 42}})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
if got, ok := panes["b"].last().(payload); !ok || got.n != 42 {
|
|
||||||
t.Fatalf("b should have received payload{42}, got %#v", panes["b"].last())
|
|
||||||
}
|
|
||||||
for _, msg := range panes["a"].msgs {
|
|
||||||
if _, ok := msg.(payload); ok {
|
|
||||||
t.Fatalf("a should not have received the SendMsg meant for b")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMsgUnknownTargetIsIgnored(t *testing.T) {
|
|
||||||
root := Leaf("a", newStub())
|
|
||||||
m, _ := newTestModel(t, root)
|
|
||||||
|
|
||||||
updated, cmd := m.Update(SendMsg{Target: "nope", Msg: struct{}{}})
|
|
||||||
_ = updated.(Model)
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatalf("expected nil cmd for an unknown SendMsg target, got %v", cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestFocusFromFocusedPaneSucceeds(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
m, panes := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
updated, _ = m.Update(RequestFocusMsg{Source: "a", Target: "b"})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
if m.state.id != "b" {
|
|
||||||
t.Fatalf("focusedID = %q, want %q", m.state.id, "b")
|
|
||||||
}
|
|
||||||
if panes["b"].focusN != 1 {
|
|
||||||
t.Fatalf("b.focusN = %d, want 1", panes["b"].focusN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestFocusFromBlurredPaneIsIgnored(t *testing.T) {
|
|
||||||
root := HSplit(0.5, Leaf("a", newStub()), Leaf("b", newStub()))
|
|
||||||
third := Leaf("c", newStub())
|
|
||||||
_ = third
|
|
||||||
m, _ := newTestModel(t, root)
|
|
||||||
updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
m = updated.(Model)
|
|
||||||
// "a" is focused; "b" (blurred) tries to redirect focus to itself.
|
|
||||||
|
|
||||||
updated, _ = m.Update(RequestFocusMsg{Source: "b", Target: "b"})
|
|
||||||
m = updated.(Model)
|
|
||||||
|
|
||||||
if m.state.id != "a" {
|
|
||||||
t.Fatalf("focusedID = %q, want %q (unauthorized request must be ignored)", m.state.id, "a")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
// SizeMsg tells a pane the exact width/height it's been allocated, and its
|
|
||||||
// own id (so it can identify itself as the Source of a later
|
|
||||||
// RequestFocusMsg). Sent to every leaf whose Rect changed, whenever the tree
|
|
||||||
// is resized, split, closed or reshaped - never assume a pane can deduce its
|
|
||||||
// size any other way.
|
|
||||||
type SizeMsg struct {
|
|
||||||
ID string
|
|
||||||
Width, Height int
|
|
||||||
}
|
|
||||||
|
|
||||||
// FocusMsg tells a pane it just gained keyboard focus. Named distinctly from
|
|
||||||
// bubbletea's own tea.FocusMsg (which is about terminal focus, not pane
|
|
||||||
// focus) to avoid any confusion between the two.
|
|
||||||
type FocusMsg struct{}
|
|
||||||
|
|
||||||
// BlurMsg tells a pane it just lost keyboard focus. See FocusMsg.
|
|
||||||
type BlurMsg struct{}
|
|
||||||
|
|
||||||
// SendMsg delivers Msg to the pane identified by Target, wherever it lives
|
|
||||||
// in the tree (including inside a nested layout.Model), regardless of which
|
|
||||||
// pane currently has focus. A pane never holds a reference to another, so
|
|
||||||
// this is how they talk: return
|
|
||||||
//
|
|
||||||
// func() tea.Msg { return layout.SendMsg{Target: "editor", Msg: myMsg{}} }
|
|
||||||
//
|
|
||||||
// as a tea.Cmd from Update. An unknown Target is silently ignored.
|
|
||||||
type SendMsg struct {
|
|
||||||
Target string
|
|
||||||
Msg tea.Msg
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequestFocusMsg asks the layout to move keyboard focus to Target. Only
|
|
||||||
// honored when Source is the id of the pane that currently holds focus (a
|
|
||||||
// blurred pane can send other panes messages via SendMsg, but can't move
|
|
||||||
// focus itself or on anyone's behalf) - fill Source from the ID a pane
|
|
||||||
// learned via SizeMsg:
|
|
||||||
//
|
|
||||||
// func() tea.Msg { return layout.RequestFocusMsg{Source: p.id, Target: "content"} }
|
|
||||||
//
|
|
||||||
// A mismatched Source, or an unknown Target, is silently ignored.
|
|
||||||
type RequestFocusMsg struct {
|
|
||||||
Source string
|
|
||||||
Target string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPaneMsg is the message form of Model.SetPane, for a pane that wants to
|
|
||||||
// swap another leaf's content without holding a reference to its Model
|
|
||||||
// (which it never does).
|
|
||||||
type SetPaneMsg struct {
|
|
||||||
ID string
|
|
||||||
NewPane Pane
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitLeafMsg is the message form of Model.SplitLeaf, for a pane that wants
|
|
||||||
// to reshape the tree without holding a reference to its Model (which it
|
|
||||||
// never does). See Model.SplitLeaf for parameters.
|
|
||||||
type SplitLeafMsg struct {
|
|
||||||
ID string
|
|
||||||
Dir Direction
|
|
||||||
NewID string
|
|
||||||
NewModel Pane
|
|
||||||
Opts []SplitOption
|
|
||||||
}
|
|
||||||
|
|
||||||
// CloseLeafMsg is the message form of Model.CloseLeaf.
|
|
||||||
type CloseLeafMsg struct {
|
|
||||||
ID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResizeMsg is the message form of Model.Resize: sets the ratio of the
|
|
||||||
// first child of the Split identified by SplitID (a Split only reachable by
|
|
||||||
// id if it was given one via WithID or WithSplitID).
|
|
||||||
type ResizeMsg struct {
|
|
||||||
SplitID string
|
|
||||||
Ratio float64
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
// FocusDirection is a screen-space direction for moving focus between
|
|
||||||
// panes: ctrl+h/j/k/l. Distinct from Direction (a Split's axis) because a
|
|
||||||
// split only ever has two sides, while focus needs to move one of four
|
|
||||||
// ways.
|
|
||||||
type FocusDirection int
|
|
||||||
|
|
||||||
const (
|
|
||||||
FocusLeft FocusDirection = iota
|
|
||||||
FocusRight
|
|
||||||
FocusUp
|
|
||||||
FocusDown
|
|
||||||
)
|
|
||||||
|
|
||||||
// FindNeighbor picks the leaf, among leaves, that's geometrically closest to
|
|
||||||
// from in dir - tmux's select-pane -L/-D/-U/-R, not a tree walk. A
|
|
||||||
// candidate must be strictly positioned beyond from's edge in dir and share
|
|
||||||
// some extent with it on the perpendicular axis. Ranked by, in order: edge
|
|
||||||
// gap (smaller wins), then shared perpendicular extent (larger wins - the
|
|
||||||
// real tie-breaker between two equally-close neighbors), then
|
|
||||||
// center-to-center distance on the perpendicular axis (last resort). Pure
|
|
||||||
// and independent of Model so it's testable on its own.
|
|
||||||
func FindNeighbor(leaves []LeafRect, from LeafRect, dir FocusDirection) (id string, ok bool) {
|
|
||||||
type candidate struct {
|
|
||||||
id string
|
|
||||||
gap, overlap, cross int
|
|
||||||
}
|
|
||||||
var best *candidate
|
|
||||||
|
|
||||||
for _, lr := range leaves {
|
|
||||||
if lr.ID == from.ID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
gap, overlap, cross, ok := edgeScore(from.Rect, lr.Rect, dir)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c := candidate{id: lr.ID, gap: gap, overlap: overlap, cross: cross}
|
|
||||||
if best == nil ||
|
|
||||||
c.gap < best.gap ||
|
|
||||||
(c.gap == best.gap && c.overlap > best.overlap) ||
|
|
||||||
(c.gap == best.gap && c.overlap == best.overlap && c.cross < best.cross) {
|
|
||||||
best = &c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if best == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return best.id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// edgeScore returns the ranking tuple used by FindNeighbor for a single
|
|
||||||
// (from, other) pair, and ok=false if other isn't a valid candidate in dir
|
|
||||||
// at all (wrong side, or zero overlap on the perpendicular axis).
|
|
||||||
func edgeScore(from, other Rect, dir FocusDirection) (gap, overlap, cross int, ok bool) {
|
|
||||||
switch dir {
|
|
||||||
case FocusLeft:
|
|
||||||
if other.X+other.W > from.X {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
gap = from.X - (other.X + other.W)
|
|
||||||
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
|
|
||||||
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
|
|
||||||
case FocusRight:
|
|
||||||
if other.X < from.X+from.W {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
gap = other.X - (from.X + from.W)
|
|
||||||
overlap = spanOverlap(from.Y, from.Y+from.H, other.Y, other.Y+other.H)
|
|
||||||
cross = abs((from.Y + from.H/2) - (other.Y + other.H/2))
|
|
||||||
case FocusUp:
|
|
||||||
if other.Y+other.H > from.Y {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
gap = from.Y - (other.Y + other.H)
|
|
||||||
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
|
|
||||||
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
|
|
||||||
case FocusDown:
|
|
||||||
if other.Y < from.Y+from.H {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
gap = other.Y - (from.Y + from.H)
|
|
||||||
overlap = spanOverlap(from.X, from.X+from.W, other.X, other.X+other.W)
|
|
||||||
cross = abs((from.X + from.W/2) - (other.X + other.W/2))
|
|
||||||
}
|
|
||||||
if overlap <= 0 {
|
|
||||||
return 0, 0, 0, false
|
|
||||||
}
|
|
||||||
return gap, overlap, cross, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// spanOverlap returns the length shared by [aStart,aEnd) and [bStart,bEnd).
|
|
||||||
func spanOverlap(aStart, aEnd, bStart, bEnd int) int {
|
|
||||||
start := max(aStart, bStart)
|
|
||||||
end := min(aEnd, bEnd)
|
|
||||||
if end <= start {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return end - start
|
|
||||||
}
|
|
||||||
|
|
||||||
func abs(n int) int {
|
|
||||||
if n < 0 {
|
|
||||||
return -n
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// leafRect looks up id's current Rect in this Model's own flat registry.
|
|
||||||
func (m Model) leafRect(id string) (LeafRect, bool) {
|
|
||||||
for _, lr := range m.leaves {
|
|
||||||
if lr.ID == id {
|
|
||||||
return lr, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return LeafRect{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveFocus implements Navigable. If the currently focused leaf holds a
|
|
||||||
// nested Navigable (an embedded layout.Model), it's given first refusal -
|
|
||||||
// only once it reports being at its own edge (false) does this Model try
|
|
||||||
// moving focus among its own direct children instead. Cmds produced by the
|
|
||||||
// BlurMsg/FocusMsg this triggers can't be returned directly (Navigable's
|
|
||||||
// signature is bool-only); they're queued on m.state.pendingCmd instead
|
|
||||||
// (see focusState) and drained by the next Update that actually returns a
|
|
||||||
// tea.Cmd - a lag of at most one Update cycle, never user-visible.
|
|
||||||
func (m Model) MoveFocus(dir FocusDirection) bool {
|
|
||||||
if focused, ok := findNode(m.root, m.state.id); ok {
|
|
||||||
if nav, ok := focused.model.(Navigable); ok {
|
|
||||||
if nav.MoveFocus(dir) {
|
|
||||||
focused.model = nav
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
from, ok := m.leafRect(m.state.id)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
id, ok := FindNeighbor(m.leaves, from, dir)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
m.state.pendingCmd = tea.Batch(m.state.pendingCmd, m.setFocus(id))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestFindNeighborBasicDirections(t *testing.T) {
|
|
||||||
leaves := []LeafRect{
|
|
||||||
{ID: "left", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
|
|
||||||
{ID: "right", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
|
|
||||||
}
|
|
||||||
|
|
||||||
if id, ok := FindNeighbor(leaves, leaves[0], FocusRight); !ok || id != "right" {
|
|
||||||
t.Fatalf("FocusRight from left: got (%q, %v), want (right, true)", id, ok)
|
|
||||||
}
|
|
||||||
if id, ok := FindNeighbor(leaves, leaves[1], FocusLeft); !ok || id != "left" {
|
|
||||||
t.Fatalf("FocusLeft from right: got (%q, %v), want (left, true)", id, ok)
|
|
||||||
}
|
|
||||||
if _, ok := FindNeighbor(leaves, leaves[0], FocusLeft); ok {
|
|
||||||
t.Fatalf("FocusLeft from left (edge): expected no neighbor")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two candidates at the exact same gap: the one that actually shares more
|
|
||||||
// of from's edge should win, not an arbitrary pick. This is the overlap
|
|
||||||
// tie-break, the one that matters most for a real tmux/i3-style layout
|
|
||||||
// (picking the pane that's genuinely alongside you, not just "a" neighbor).
|
|
||||||
func TestFindNeighborPrefersLargerOverlapOnEqualGap(t *testing.T) {
|
|
||||||
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}}
|
|
||||||
leaves := []LeafRect{
|
|
||||||
from,
|
|
||||||
{ID: "full", Rect: Rect{X: 10, Y: 0, W: 10, H: 10}},
|
|
||||||
{ID: "partial", Rect: Rect{X: 10, Y: 5, W: 10, H: 10}},
|
|
||||||
}
|
|
||||||
|
|
||||||
id, ok := FindNeighbor(leaves, from, FocusRight)
|
|
||||||
if !ok || id != "full" {
|
|
||||||
t.Fatalf("got (%q, %v), want (full, true): equal gap, full's overlap (10) beats partial's (5)", id, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// When gap AND overlap are tied, center-to-center distance on the
|
|
||||||
// perpendicular axis is the last resort.
|
|
||||||
func TestFindNeighborCrossIsLastResortTiebreak(t *testing.T) {
|
|
||||||
from := LeafRect{ID: "from", Rect: Rect{X: 0, Y: 10, W: 10, H: 10}} // Y 10..20, center 15
|
|
||||||
leaves := []LeafRect{
|
|
||||||
from,
|
|
||||||
{ID: "near", Rect: Rect{X: 10, Y: 8, W: 10, H: 20}}, // Y 8..28, overlap 10, center 18
|
|
||||||
{ID: "far", Rect: Rect{X: 10, Y: 0, W: 10, H: 20}}, // Y 0..20, overlap 10, center 10
|
|
||||||
}
|
|
||||||
|
|
||||||
id, ok := FindNeighbor(leaves, from, FocusRight)
|
|
||||||
if !ok || id != "near" {
|
|
||||||
t.Fatalf("got (%q, %v), want (near, true): equal gap and overlap, near's center is closer (3 vs 5)", id, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindNeighborNoCandidateBeyondEdge(t *testing.T) {
|
|
||||||
leaves := []LeafRect{
|
|
||||||
{ID: "only", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
|
|
||||||
}
|
|
||||||
if _, ok := FindNeighbor(leaves, leaves[0], FocusDown); ok {
|
|
||||||
t.Fatal("expected no neighbor with a single leaf")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFindNeighborRequiresPerpendicularOverlap(t *testing.T) {
|
|
||||||
leaves := []LeafRect{
|
|
||||||
{ID: "from", Rect: Rect{X: 0, Y: 0, W: 10, H: 10}},
|
|
||||||
// Directly to the right on the X axis, but no shared Y extent at
|
|
||||||
// all: not a valid candidate even though it's the only one there.
|
|
||||||
{ID: "diagonal", Rect: Rect{X: 10, Y: 10, W: 10, H: 10}},
|
|
||||||
}
|
|
||||||
if _, ok := FindNeighbor(leaves, leaves[0], FocusRight); ok {
|
|
||||||
t.Fatal("expected no neighbor: candidate shares no Y extent with from")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-210
@@ -1,210 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
// Direction is the axis a Split divides its space along.
|
|
||||||
type Direction int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Horizontal places the first child on the left, the second on the right.
|
|
||||||
Horizontal Direction = iota
|
|
||||||
// Vertical stacks the first child on top of the second.
|
|
||||||
Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
// Node is one slot in the layout tree: either a Leaf (holding a Pane) or a
|
|
||||||
// Split (dividing its space between two child Nodes).
|
|
||||||
// Build a tree with Leaf and Split/HSplit/VSplit, then hand the root to New.
|
|
||||||
//
|
|
||||||
// Node is pointer-based on purpose: panes need a stable identity across
|
|
||||||
// arbitrarily nested splits, so there's no flat index to keep in sync the
|
|
||||||
// way a slice-based component would.
|
|
||||||
type Node struct {
|
|
||||||
id string // "" means unaddressable; always non-empty for a Leaf
|
|
||||||
|
|
||||||
leaf bool
|
|
||||||
model Pane // set when leaf
|
|
||||||
|
|
||||||
dir Direction // set when !leaf
|
|
||||||
ratio float64 // proportion of space given to first; set when !leaf
|
|
||||||
min, max int // clamp on first's resolved cell size, in cells; 0 = unset
|
|
||||||
first, second *Node // set when !leaf
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leaf wraps a single pane. id must be non-empty and unique within the tree
|
|
||||||
// it ends up in: it's how the pane is targeted later by SendMsg,
|
|
||||||
// RequestFocusMsg, SplitLeaf, CloseLeaf and Resize.
|
|
||||||
func Leaf(id string, model Pane) *Node {
|
|
||||||
return &Node{id: id, leaf: true, model: model}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split divides its space between first and second along dir, giving ratio
|
|
||||||
// (0 to 1) of it to first and the rest to second. id may be "" if the split
|
|
||||||
// itself never needs to be addressed by Resize; it plays no role in pane
|
|
||||||
// addressing (only Leaf ids do).
|
|
||||||
func Split(id string, dir Direction, ratio float64, first, second *Node) *Node {
|
|
||||||
return &Node{id: id, dir: dir, ratio: ratio, first: first, second: second}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HSplit is Split with Horizontal and no id: layout.HSplit(0.3, left, right).
|
|
||||||
func HSplit(ratio float64, first, second *Node) *Node {
|
|
||||||
return Split("", Horizontal, ratio, first, second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VSplit is Split with Vertical and no id: layout.VSplit(0.3, top, bottom).
|
|
||||||
func VSplit(ratio float64, first, second *Node) *Node {
|
|
||||||
return Split("", Vertical, ratio, first, second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithID sets the id used to address this node later (currently only
|
|
||||||
// meaningful on a Split, for Resize; a Leaf already gets its id from Leaf).
|
|
||||||
func (n *Node) WithID(id string) *Node {
|
|
||||||
n.id = id
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithMinimum clamps first's resolved size to never go below cells. No-op on
|
|
||||||
// a Leaf, which has no size of its own to constrain.
|
|
||||||
func (n *Node) WithMinimum(cells int) *Node {
|
|
||||||
if !n.leaf {
|
|
||||||
n.min = cells
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithMaximum clamps first's resolved size to never exceed cells. No-op on a
|
|
||||||
// Leaf. Setting both WithMinimum and WithMaximum to the same value fixes
|
|
||||||
// first's size regardless of ratio.
|
|
||||||
func (n *Node) WithMaximum(cells int) *Node {
|
|
||||||
if !n.leaf {
|
|
||||||
n.max = cells
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// findNode returns the node with the given id anywhere in the tree rooted at
|
|
||||||
// n, without mutating anything. id == "" never matches (it's the
|
|
||||||
// unaddressable sentinel, and several Splits may share it).
|
|
||||||
func findNode(n *Node, id string) (*Node, bool) {
|
|
||||||
if n == nil || id == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if n.id == id {
|
|
||||||
return n, true
|
|
||||||
}
|
|
||||||
if n.leaf {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if found, ok := findNode(n.first, id); ok {
|
|
||||||
return found, true
|
|
||||||
}
|
|
||||||
return findNode(n.second, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitConfig collects SplitOption values applied by SplitLeaf.
|
|
||||||
type splitConfig struct {
|
|
||||||
id string
|
|
||||||
ratio float64
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitOption configures the Split node SplitLeaf creates in place of the
|
|
||||||
// leaf it splits.
|
|
||||||
type SplitOption func(*Node, *splitConfig)
|
|
||||||
|
|
||||||
// WithSplitID addresses the split SplitLeaf creates, so it can later be
|
|
||||||
// targeted by Resize.
|
|
||||||
func WithSplitID(id string) SplitOption {
|
|
||||||
return func(_ *Node, c *splitConfig) { c.id = id }
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSplitRatio overrides SplitLeaf's default 50/50 split.
|
|
||||||
func WithSplitRatio(ratio float64) SplitOption {
|
|
||||||
return func(_ *Node, c *splitConfig) { c.ratio = ratio }
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSplitMinimum clamps the size of the original (pre-split) leaf's side
|
|
||||||
// of the new split. Equivalent to calling (*Node).WithMinimum on the split
|
|
||||||
// SplitLeaf produces.
|
|
||||||
func WithSplitMinimum(cells int) SplitOption {
|
|
||||||
return func(n *Node, _ *splitConfig) { n.WithMinimum(cells) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithSplitMaximum clamps the size of the original (pre-split) leaf's side
|
|
||||||
// of the new split. Equivalent to calling (*Node).WithMaximum on the split
|
|
||||||
// SplitLeaf produces.
|
|
||||||
func WithSplitMaximum(cells int) SplitOption {
|
|
||||||
return func(n *Node, _ *splitConfig) { n.WithMaximum(cells) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitLeaf replaces the leaf identified by id with a Split holding the
|
|
||||||
// original leaf as first and a new Leaf(newID, newModel) as second. Returns
|
|
||||||
// the (possibly new) tree root and whether id was found and was a leaf.
|
|
||||||
func splitLeaf(root *Node, id string, dir Direction, newID string, newModel Pane, opts ...SplitOption) (*Node, bool) {
|
|
||||||
target, ok := findNode(root, id)
|
|
||||||
if !ok || !target.leaf {
|
|
||||||
return root, false
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := splitConfig{ratio: 0.5}
|
|
||||||
split := Split("", dir, 0.5, target, Leaf(newID, newModel))
|
|
||||||
for _, opt := range opts {
|
|
||||||
opt(split, &cfg)
|
|
||||||
}
|
|
||||||
split.id = cfg.id
|
|
||||||
split.ratio = cfg.ratio
|
|
||||||
|
|
||||||
newRoot, _ := replaceNode(root, id, func(*Node) *Node { return split })
|
|
||||||
return newRoot, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// replaceNode returns a copy of the tree rooted at n with the node
|
|
||||||
// identified by id swapped for transform's result. Only the path down to
|
|
||||||
// that node is cloned, everything else is shared. Returns n unchanged and
|
|
||||||
// false if id isn't found.
|
|
||||||
func replaceNode(n *Node, id string, transform func(*Node) *Node) (*Node, bool) {
|
|
||||||
if n == nil || id == "" {
|
|
||||||
return n, false
|
|
||||||
}
|
|
||||||
if n.id == id {
|
|
||||||
return transform(n), true
|
|
||||||
}
|
|
||||||
if n.leaf {
|
|
||||||
return n, false
|
|
||||||
}
|
|
||||||
if newFirst, ok := replaceNode(n.first, id, transform); ok {
|
|
||||||
clone := *n
|
|
||||||
clone.first = newFirst
|
|
||||||
return &clone, true
|
|
||||||
}
|
|
||||||
if newSecond, ok := replaceNode(n.second, id, transform); ok {
|
|
||||||
clone := *n
|
|
||||||
clone.second = newSecond
|
|
||||||
return &clone, true
|
|
||||||
}
|
|
||||||
return n, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeLeaf removes the leaf identified by id, promoting its sibling to take
|
|
||||||
// the place of their parent Split. Returns the (possibly new) tree root and
|
|
||||||
// whether id was found as a direct child of some Split (the tree's own root
|
|
||||||
// leaf, with no parent, can never be closed this way).
|
|
||||||
func closeLeaf(root *Node, id string) (*Node, bool) {
|
|
||||||
if root == nil || root.leaf || id == "" {
|
|
||||||
return root, false
|
|
||||||
}
|
|
||||||
if root.first.leaf && root.first.id == id {
|
|
||||||
return root.second, true
|
|
||||||
}
|
|
||||||
if root.second.leaf && root.second.id == id {
|
|
||||||
return root.first, true
|
|
||||||
}
|
|
||||||
if newFirst, ok := closeLeaf(root.first, id); ok {
|
|
||||||
clone := *root
|
|
||||||
clone.first = newFirst
|
|
||||||
return &clone, true
|
|
||||||
}
|
|
||||||
if newSecond, ok := closeLeaf(root.second, id); ok {
|
|
||||||
clone := *root
|
|
||||||
clone.second = newSecond
|
|
||||||
return &clone, true
|
|
||||||
}
|
|
||||||
return root, false
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
// Route implements Navigable: attempts to deliver msg to the leaf
|
|
||||||
// identified by target. Checks this Model's own direct leaves first, then
|
|
||||||
// recurses into any leaf whose model is itself Navigable (an embedded
|
|
||||||
// layout.Model), depth-first. Returns handled=false without touching
|
|
||||||
// anything if target isn't found anywhere in this (sub)tree - the caller
|
|
||||||
// (Model.Update, or a parent Model's own Route) is responsible for treating
|
|
||||||
// that as "silently ignore."
|
|
||||||
func (m Model) Route(target string, msg tea.Msg) (bool, tea.Cmd) {
|
|
||||||
if n, ok := findNode(m.root, target); ok && n.leaf {
|
|
||||||
updated, cmd := n.model.Update(msg)
|
|
||||||
n.model = updated
|
|
||||||
return true, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
handled bool
|
|
||||||
cmd tea.Cmd
|
|
||||||
)
|
|
||||||
walk(m.root, func(n *Node) {
|
|
||||||
if handled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if nav, ok := n.model.(Navigable); ok {
|
|
||||||
if h, c := nav.Route(target, msg); h {
|
|
||||||
n.model = nav
|
|
||||||
handled, cmd = true, c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return handled, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// Focus implements Navigable: moves this (sub)tree's focus straight to id,
|
|
||||||
// wherever it is - a direct leaf, or nested inside a leaf's own Navigable.
|
|
||||||
// Unlike MoveFocus (one geometric step in a direction), this is "jump to
|
|
||||||
// this specific pane." When id lives inside a nested Navigable, that
|
|
||||||
// child's own internal focus is set first, and then this Model's own focus
|
|
||||||
// is brought to the leaf hosting it too, so the whole chain agrees on what's
|
|
||||||
// focused (required for FocusMsg/BlurMsg propagation and for MoveFocus's
|
|
||||||
// "ask the focused child first" rule to keep working afterward). That outer
|
|
||||||
// step re-notifies the leaf hosting the nested tree regardless of whether
|
|
||||||
// the nested Focus call already notified id directly - the two can't always
|
|
||||||
// be told apart cheaply (id might have already been that subtree's
|
|
||||||
// untouched default focus, which never got an initial FocusMsg at all, see
|
|
||||||
// Model.Init) - so id's pane may occasionally see FocusMsg twice for one
|
|
||||||
// real transition. Delivery is at-least-once, not exactly-once: a Pane
|
|
||||||
// should treat FocusMsg/BlurMsg as idempotent, the same way it would have
|
|
||||||
// to tolerate a redundant terminal focus event.
|
|
||||||
func (m Model) Focus(id string) (bool, tea.Cmd) {
|
|
||||||
if _, ok := m.leafRect(id); ok {
|
|
||||||
return true, m.setFocus(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
handled bool
|
|
||||||
innerCmd tea.Cmd
|
|
||||||
outerID string
|
|
||||||
)
|
|
||||||
walk(m.root, func(n *Node) {
|
|
||||||
if handled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if nav, ok := n.model.(Navigable); ok {
|
|
||||||
if h, c := nav.Focus(id); h {
|
|
||||||
n.model = nav
|
|
||||||
handled, innerCmd, outerID = true, c, n.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if !handled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return true, tea.Batch(m.setFocus(outerID), innerCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sourceIsFocused reports whether id is the leaf currently focused
|
|
||||||
// somewhere along this (sub)tree's active focus chain: either this Model's
|
|
||||||
// own focused leaf, or - recursively - whatever's focused inside that leaf
|
|
||||||
// if it's itself a nested Navigable. Used to authorize RequestFocusMsg: only
|
|
||||||
// the pane that genuinely holds focus right now, at whatever depth, is
|
|
||||||
// allowed to redirect focus elsewhere. A blurred pane reaching this code
|
|
||||||
// (e.g. reacting to a SendMsg while in the background) is correctly refused
|
|
||||||
// since it can never appear on the active chain.
|
|
||||||
func (m Model) sourceIsFocused(id string) bool {
|
|
||||||
if m.state.id == id {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
focused, ok := findNode(m.root, m.state.id)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
checker, ok := focused.model.(interface{ sourceIsFocused(string) bool })
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return checker.sourceIsFocused(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRequestFocus honors a RequestFocusMsg only once sourceIsFocused
|
|
||||||
// clears its Source, then resolves Target the same way Focus does. An
|
|
||||||
// unauthorized Source, or an unknown Target, is silently ignored.
|
|
||||||
func (m Model) handleRequestFocus(msg RequestFocusMsg) tea.Cmd {
|
|
||||||
if !m.sourceIsFocused(msg.Source) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, cmd := m.Focus(msg.Target)
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package layout
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/key"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// buildNested returns an outer Model with two direct leaves: "sidebar" and
|
|
||||||
// "inner-root", the latter being a nested layout.Model (itself split into
|
|
||||||
// "inner-a"/"inner-b") embedded as an ordinary Pane. Both models are sized
|
|
||||||
// before being handed back so their leaf registries are populated.
|
|
||||||
func buildNested(t *testing.T) (outer Model, sidebar *stubPane, innerA, innerB *stubPane) {
|
|
||||||
t.Helper()
|
|
||||||
sidebar = newStub()
|
|
||||||
innerA = newStub()
|
|
||||||
innerB = newStub()
|
|
||||||
|
|
||||||
// inner is deliberately built WITHOUT AsRoot(): it's embedded, so it
|
|
||||||
// must not act focused on its own until outer actually focuses the
|
|
||||||
// leaf that hosts it (see Model.Init's asRoot guard).
|
|
||||||
inner := New(HSplit(0.5, Leaf("inner-a", innerA), Leaf("inner-b", innerB)))
|
|
||||||
|
|
||||||
root := HSplit(0.3, Leaf("sidebar", sidebar), Leaf("inner-root", inner))
|
|
||||||
outer = New(root, AsRoot())
|
|
||||||
outer.Init()
|
|
||||||
updated, _ := outer.Update(tea.WindowSizeMsg{Width: 100, Height: 40})
|
|
||||||
outer = updated.(Model)
|
|
||||||
return outer, sidebar, innerA, innerB
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression test: a nested Model used to fire its own initial FocusMsg to
|
|
||||||
// its first leaf unconditionally on Init, regardless of whether the outer
|
|
||||||
// tree's actual initial focus ever lands on the leaf hosting it - so a
|
|
||||||
// leaf buried in a subtree that isn't even initially focused would still
|
|
||||||
// show up as focused, alongside whatever the outer tree really focused.
|
|
||||||
func TestEmbeddedModelDoesNotSelfFocusOnInit(t *testing.T) {
|
|
||||||
outer, sidebar, innerA, innerB := buildNested(t)
|
|
||||||
|
|
||||||
if sidebar.focusN != 1 {
|
|
||||||
t.Fatalf("sidebar.focusN = %d, want 1 (it's the outer tree's real initial focus)", sidebar.focusN)
|
|
||||||
}
|
|
||||||
if innerA.focusN != 0 || innerB.focusN != 0 {
|
|
||||||
t.Fatalf("innerA.focusN=%d innerB.focusN=%d, want 0/0: the nested tree isn't focused yet", innerA.focusN, innerB.focusN)
|
|
||||||
}
|
|
||||||
if outer.state.id != "sidebar" {
|
|
||||||
t.Fatalf("outer focusedID = %q, want %q", outer.state.id, "sidebar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSendMsgReachesNestedLeaf(t *testing.T) {
|
|
||||||
type payload struct{ n int }
|
|
||||||
outer, _, innerA, _ := buildNested(t)
|
|
||||||
|
|
||||||
updated, _ := outer.Update(SendMsg{Target: "inner-a", Msg: payload{n: 7}})
|
|
||||||
_ = updated.(Model)
|
|
||||||
|
|
||||||
if got, ok := innerA.last().(payload); !ok || got.n != 7 {
|
|
||||||
t.Fatalf("inner-a should have received payload{7}, got %#v", innerA.last())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFocusJumpsIntoNestedSubtreeAndUpdatesOuterFocus(t *testing.T) {
|
|
||||||
outer, _, _, innerB := buildNested(t)
|
|
||||||
// Outer's own focus starts on "sidebar" (first leaf, depth-first).
|
|
||||||
|
|
||||||
handled, _ := outer.Focus("inner-b")
|
|
||||||
if !handled {
|
|
||||||
t.Fatal("Focus(\"inner-b\") should have been handled")
|
|
||||||
}
|
|
||||||
if outer.state.id != "inner-root" {
|
|
||||||
t.Fatalf("outer focusedID = %q, want %q (the leaf hosting the nested tree)", outer.state.id, "inner-root")
|
|
||||||
}
|
|
||||||
// At-least-once, not exactly-once (see Focus's doc comment): the outer
|
|
||||||
// leaf's own re-notification can duplicate the nested tree's own
|
|
||||||
// dispatch, so only assert inner-b actually got notified, not a count.
|
|
||||||
if innerB.focusN < 1 {
|
|
||||||
t.Fatalf("inner-b.focusN = %d, want at least 1", innerB.focusN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestFocusAuthorizedThroughNestedChain(t *testing.T) {
|
|
||||||
outer, _, innerA, _ := buildNested(t)
|
|
||||||
|
|
||||||
// Move outer focus onto the nested subtree, and its own internal focus
|
|
||||||
// onto inner-a, so inner-a is genuinely the focused leaf end-to-end.
|
|
||||||
outer.Focus("inner-a")
|
|
||||||
if innerA.focusN != 1 {
|
|
||||||
t.Fatalf("inner-a.focusN = %d, want 1 before the request", innerA.focusN)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-a", Target: "sidebar"})
|
|
||||||
outer = updated.(Model)
|
|
||||||
|
|
||||||
if outer.state.id != "sidebar" {
|
|
||||||
t.Fatalf("focusedID = %q, want %q: inner-a is genuinely focused, its request should be honored", outer.state.id, "sidebar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRequestFocusFromNonFocusedNestedLeafIsIgnored(t *testing.T) {
|
|
||||||
outer, _, _, innerB := buildNested(t)
|
|
||||||
// Outer focus is on "sidebar"; the nested tree isn't even the focused
|
|
||||||
// branch, so nothing inside it - including inner-b - is authorized.
|
|
||||||
_ = innerB
|
|
||||||
|
|
||||||
updated, _ := outer.Update(RequestFocusMsg{Source: "inner-b", Target: "sidebar"})
|
|
||||||
outer = updated.(Model)
|
|
||||||
|
|
||||||
if outer.state.id != "sidebar" {
|
|
||||||
t.Fatalf("focusedID = %q, want unchanged %q", outer.state.id, "sidebar")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveFocusDelegatesToNestedBeforeGeometry(t *testing.T) {
|
|
||||||
outer, _, _, innerB := buildNested(t)
|
|
||||||
outer.Focus("inner-a")
|
|
||||||
|
|
||||||
if ok := outer.MoveFocus(FocusRight); !ok {
|
|
||||||
t.Fatal("MoveFocus(FocusRight) should have been handled by the nested tree (inner-a -> inner-b)")
|
|
||||||
}
|
|
||||||
if innerB.focusN != 1 {
|
|
||||||
t.Fatalf("inner-b.focusN = %d, want 1 (moved within the nested tree)", innerB.focusN)
|
|
||||||
}
|
|
||||||
if outer.state.id != "inner-root" {
|
|
||||||
t.Fatalf("outer focusedID changed to %q, should have stayed on the nested leaf", outer.state.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now at inner-b, the nested tree's own rightmost leaf: pressing right
|
|
||||||
// again must bubble up and move the OUTER focus instead.
|
|
||||||
if ok := outer.MoveFocus(FocusRight); ok {
|
|
||||||
// buildNested's outer split is only sidebar|inner-root left-to-right,
|
|
||||||
// so there's nothing further right at the outer level either -
|
|
||||||
// MoveFocus should report false all the way up.
|
|
||||||
t.Fatalf("expected no further neighbor to the right at either level")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHelpBindingsDelegatesThroughNesting(t *testing.T) {
|
|
||||||
outer, _, innerA, _ := buildNested(t)
|
|
||||||
outer.Focus("inner-a")
|
|
||||||
|
|
||||||
want := key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "do x"))
|
|
||||||
innerA.help = []key.Binding{want}
|
|
||||||
|
|
||||||
got := outer.HelpBindings()
|
|
||||||
if len(got) != 1 || got[0].Help().Key != "x" {
|
|
||||||
t.Fatalf("HelpBindings() = %#v, want the focused inner leaf's bindings", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+54
-18
@@ -4,9 +4,8 @@ A centered popup box on top of a dimmed background, triggered from anywhere in a
|
|||||||
program via an exported `tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the
|
program via an exported `tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the
|
||||||
`Model` that ends up rendering it - standard Elm architecture, no IPC between processes.
|
`Model` that ends up rendering it - standard Elm architecture, no IPC between processes.
|
||||||
|
|
||||||
It composites over an already-rendered string, so it has no dependency on
|
It composites over an already-rendered string, so it makes no assumption about how the host
|
||||||
`github.com/anotherhadi/ilovetui/layout`: the same `Model` works whether the host uses `layout`
|
builds that string: the same `Model` works whatever the host uses to lay out its main content.
|
||||||
for its main content or not.
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyPressMsg:
|
case tea.KeyPressMsg:
|
||||||
if msg.String() == "d" {
|
if msg.String() == "d" {
|
||||||
return m, modal.Show("Delete file?", "This can't be undone.\n\ny: confirm esc: cancel")
|
return m, modal.Show("Delete file?", modal.Text("This can't be undone.\n\ny: confirm esc: cancel"))
|
||||||
}
|
}
|
||||||
if msg.String() == "esc" && m.m.Open() {
|
if msg.String() == "esc" && m.m.Open() {
|
||||||
return m, modal.Close()
|
return m, modal.Close()
|
||||||
@@ -55,37 +54,74 @@ a reference to the `modal.Model` that will actually render it - that `Model` jus
|
|||||||
every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level `Update`),
|
every `tea.Msg` the program produces (i.e. get its `Update` called from the top-level `Update`),
|
||||||
same as any other child model.
|
same as any other child model.
|
||||||
|
|
||||||
## Showing and dismissing
|
## Content is a model
|
||||||
|
|
||||||
|
A modal's body is a `tea.Model`, not a string. While a modal is on top of the stack it gets every
|
||||||
|
message the `modal.Model` receives, its `Init` runs when it opens, and its commands come back out
|
||||||
|
- so it can hold a form, a list, or a confirmation that reports its answer with a `tea.Msg` of
|
||||||
|
its own, which the component that opened it listens for:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
return m, modal.Show("Delete file?", "This can't be undone.", modal.WithID("confirm"))
|
type confirmedMsg struct{ path string }
|
||||||
|
|
||||||
return m, modal.Dismiss("confirm") // close a specific modal by id
|
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m, modal.Close() // close whichever modal is on top, whatever its id
|
if k, ok := msg.(tea.KeyPressMsg); ok && k.String() == "y" {
|
||||||
|
path := c.path
|
||||||
|
return c, func() tea.Msg { return confirmedMsg{path} }
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// somewhere else
|
||||||
|
return m, modal.Show("Delete file?", confirm{path: p})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only the topmost modal is updated: everything beneath it is dimmed and frozen until the modals
|
||||||
|
above it close.
|
||||||
|
|
||||||
|
For a modal with nothing to interact with, `modal.Text` wraps a plain string:
|
||||||
|
|
||||||
|
```go
|
||||||
|
return m, modal.Show("About", modal.Text("v1.0\n\nesc to close"))
|
||||||
|
```
|
||||||
|
|
||||||
|
The box shrinks to fit whatever the content draws, so a content that wants a specific size sets
|
||||||
|
it on itself - the modal only ever sees the rendered result.
|
||||||
|
|
||||||
|
## Showing and closing
|
||||||
|
|
||||||
|
```go
|
||||||
|
return m, modal.Show("Delete file?", confirm{})
|
||||||
|
|
||||||
|
return m, modal.Close() // close the topmost modal
|
||||||
|
```
|
||||||
|
|
||||||
|
The stack is a plain LIFO, with no identity: a modal is closed by being on top, never by being
|
||||||
|
named. There is nothing to tag a modal with, and nothing that can target one in the middle of the
|
||||||
|
stack - the topmost is both the only one that receives messages and the only one `Close` can
|
||||||
|
reach, so the two rules never disagree.
|
||||||
|
|
||||||
- `modal.Open()` reports whether at least one modal is currently shown - handy for a host that
|
- `modal.Open()` reports whether at least one modal is currently shown - handy for a host that
|
||||||
wants to route key presses to the modal (e.g. `esc` to dismiss, `enter` to confirm) instead of
|
wants to route key presses to the modal instead of its normal UI while one is open. Note that a
|
||||||
its normal UI while one is open.
|
host doing this also makes it impossible for a key to open a second modal, which is what keeps
|
||||||
- `modal.TopID()` returns the id of the topmost (currently interactive) modal, or `""` if none is
|
the stack shallow without any bookkeeping.
|
||||||
open - useful to tell which modal a generic key like `enter` should act on.
|
- A content model closes its own modal by returning `modal.Close()`, since it only ever runs while
|
||||||
- A modal shown without `WithID` gets an auto-generated id that's never returned to the caller -
|
it is the topmost one.
|
||||||
only a modal shown with `WithID` can be targeted by `Dismiss` later. Showing again with the same
|
|
||||||
id replaces it in place instead of stacking a duplicate.
|
|
||||||
|
|
||||||
## Stacking
|
## Stacking
|
||||||
|
|
||||||
Modals stack: showing a second one while the first is still open pushes it on top, dimming both
|
Modals stack: showing a second one while the first is still open pushes it on top, dimming both
|
||||||
the background and the first modal to the same flat color. Dismissing the top one reveals the one
|
the background and the first modal to the same flat color. Closing the top one reveals the one
|
||||||
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
|
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
|
||||||
sure?" modal without any special-casing.
|
sure?" modal without any special-casing - the nested one is pushed by the content on top, the only
|
||||||
|
one able to act.
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
```go
|
```go
|
||||||
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
|
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
|
||||||
|
|
||||||
return m, modal.Show("Title", "Message", modal.WithModalStyle(oneOffStyles))
|
return m, modal.Show("Title", modal.Text("Message"), modal.WithModalStyle(oneOffStyles))
|
||||||
```
|
```
|
||||||
|
|
||||||
`WithMaxWidth`/`WithMaxHeight` cap how large a modal box can grow before wrapping/truncating; a
|
`WithMaxWidth`/`WithMaxHeight` cap how large a modal box can grow before wrapping/truncating; a
|
||||||
|
|||||||
+37
-27
@@ -2,12 +2,13 @@ package modal
|
|||||||
|
|
||||||
import tea "charm.land/bubbletea/v2"
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
// Modal is one popup. Build it via Show's opts rather than a literal: ID
|
// Modal is one popup on the stack.
|
||||||
// gets a default (see WithID) that a bare literal would silently skip.
|
|
||||||
type Modal struct {
|
type Modal struct {
|
||||||
ID string
|
|
||||||
Title string
|
Title string
|
||||||
Content string
|
// Content is the modal's body: a full model, updated and rendered by
|
||||||
|
// the modal.Model while it's on top of the stack. Wrap a plain string
|
||||||
|
// with Text for a modal with nothing to interact with.
|
||||||
|
Content tea.Model
|
||||||
// Style, if non-nil, overrides the Model's default Styles for this
|
// Style, if non-nil, overrides the Model's default Styles for this
|
||||||
// modal alone.
|
// modal alone.
|
||||||
Style *Styles
|
Style *Styles
|
||||||
@@ -16,20 +17,13 @@ type Modal struct {
|
|||||||
// ModalOption configures a Modal built by Show.
|
// ModalOption configures a Modal built by Show.
|
||||||
type ModalOption func(*Modal)
|
type ModalOption func(*Modal)
|
||||||
|
|
||||||
// WithID gives the modal a stable id, so a later Show reusing the same id
|
|
||||||
// replaces it in place instead of pushing a duplicate on the stack, and so
|
|
||||||
// it can be targeted by Dismiss.
|
|
||||||
func WithID(id string) ModalOption {
|
|
||||||
return func(mo *Modal) { mo.ID = id }
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithModalStyle overrides the Model's default Styles for this modal alone,
|
// WithModalStyle overrides the Model's default Styles for this modal alone,
|
||||||
// for a one-off custom look instead of the shared theme.
|
// for a one-off custom look instead of the shared theme.
|
||||||
func WithModalStyle(s Styles) ModalOption {
|
func WithModalStyle(s Styles) ModalOption {
|
||||||
return func(mo *Modal) { mo.Style = &s }
|
return func(mo *Modal) { mo.Style = &s }
|
||||||
}
|
}
|
||||||
|
|
||||||
func newModal(title, content string, opts ...ModalOption) Modal {
|
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
|
||||||
mo := Modal{Title: title, Content: content}
|
mo := Modal{Title: title, Content: content}
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(&mo)
|
opt(&mo)
|
||||||
@@ -44,27 +38,43 @@ func newModal(title, content string, opts ...ModalOption) Modal {
|
|||||||
// produces, same as any other child model.
|
// produces, same as any other child model.
|
||||||
type ShowMsg struct{ Modal Modal }
|
type ShowMsg struct{ Modal Modal }
|
||||||
|
|
||||||
// Show returns a tea.Cmd that opens a new modal on top of the stack:
|
// Show returns a tea.Cmd that opens a new modal on top of the stack. content
|
||||||
|
// is a model, so a modal can hold anything a pane can - a form, a list, a
|
||||||
|
// confirmation that reports back with its own tea.Msg:
|
||||||
//
|
//
|
||||||
// return m, modal.Show("Delete file?", "This can't be undone.")
|
// return m, modal.Show("Delete file?", modal.Text("This can't be undone."))
|
||||||
func Show(title, content string, opts ...ModalOption) tea.Cmd {
|
// return m, modal.Show("Rename", newRenameForm(path))
|
||||||
|
//
|
||||||
|
// The content's Init runs when the modal opens, and it receives every message
|
||||||
|
// while it's the topmost modal (see Model.Update).
|
||||||
|
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
|
||||||
mo := newModal(title, content, opts...)
|
mo := newModal(title, content, opts...)
|
||||||
return func() tea.Msg { return ShowMsg{Modal: mo} }
|
return func() tea.Msg { return ShowMsg{Modal: mo} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// DismissMsg closes the modal identified by ID, or the topmost modal if ID
|
// DismissMsg closes the topmost modal. The stack is a plain LIFO: a modal is
|
||||||
// is empty.
|
// closed by being on top, never by being named.
|
||||||
type DismissMsg struct{ ID string }
|
type DismissMsg struct{}
|
||||||
|
|
||||||
// Dismiss returns a tea.Cmd that closes the modal identified by id. Only
|
// Close returns a tea.Cmd that closes the topmost modal - which is also the
|
||||||
// useful for modals shown with WithID.
|
// only one that can act (see Model.Update), so a content model closes itself
|
||||||
func Dismiss(id string) tea.Cmd {
|
// by returning it:
|
||||||
return func() tea.Msg { return DismissMsg{ID: id} }
|
//
|
||||||
}
|
// return c, modal.Close()
|
||||||
|
|
||||||
// Close returns a tea.Cmd that closes the topmost modal, whatever its id -
|
|
||||||
// the common case of a host handling esc/"cancel" without needing to know
|
|
||||||
// which modal is currently on top.
|
|
||||||
func Close() tea.Cmd {
|
func Close() tea.Cmd {
|
||||||
return func() tea.Msg { return DismissMsg{} }
|
return func() tea.Msg { return DismissMsg{} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// text is a model wrapping a fixed string: a modal body with nothing to
|
||||||
|
// update.
|
||||||
|
type text string
|
||||||
|
|
||||||
|
func (t text) Init() tea.Cmd { return nil }
|
||||||
|
func (t text) Update(tea.Msg) (tea.Model, tea.Cmd) { return t, nil }
|
||||||
|
func (t text) View() tea.View { return tea.NewView(string(t)) }
|
||||||
|
|
||||||
|
// Text wraps a plain string as modal content, for the common modal that has
|
||||||
|
// nothing to interact with:
|
||||||
|
//
|
||||||
|
// modal.Show("About", modal.Text("v1.0\n\nesc to close"))
|
||||||
|
func Text(s string) tea.Model { return text(s) }
|
||||||
|
|||||||
+41
-44
@@ -4,16 +4,17 @@
|
|||||||
// up rendering it.
|
// up rendering it.
|
||||||
//
|
//
|
||||||
// It composites over an already-rendered string (see Model.Render), so it
|
// It composites over an already-rendered string (see Model.Render), so it
|
||||||
// has no dependency on github.com/anotherhadi/ilovetui/layout: the same
|
// makes no assumption at all about how the host builds that string: the same
|
||||||
// Model works whether the host uses layout for its main content or not (see
|
// Model works whatever the host uses to lay out its main content (see
|
||||||
// Model.Render and Model.View).
|
// Model.Render and Model.View).
|
||||||
|
//
|
||||||
|
// A modal's content is a model, not a string: it is updated while it's on
|
||||||
|
// top of the stack, so it can hold anything a pane can - a form, a list, a
|
||||||
|
// confirmation reporting its answer back with its own tea.Msg. See Show and
|
||||||
|
// Text.
|
||||||
package modal
|
package modal
|
||||||
|
|
||||||
import (
|
import tea "charm.land/bubbletea/v2"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Model holds the currently open modals (a stack: the most recently shown
|
// Model holds the currently open modals (a stack: the most recently shown
|
||||||
// is drawn on top, everything beneath it - the background and any earlier
|
// is drawn on top, everything beneath it - the background and any earlier
|
||||||
@@ -21,7 +22,6 @@ import (
|
|||||||
// they share. Build one with New.
|
// they share. Build one with New.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
modals []Modal
|
modals []Modal
|
||||||
nextID int
|
|
||||||
maxWidth int
|
maxWidth int
|
||||||
maxHeight int
|
maxHeight int
|
||||||
styles Styles
|
styles Styles
|
||||||
@@ -68,58 +68,55 @@ func (m Model) Init() tea.Cmd { return nil }
|
|||||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case ShowMsg:
|
case ShowMsg:
|
||||||
return m.show(msg.Modal), nil
|
return m.show(msg.Modal)
|
||||||
case DismissMsg:
|
case DismissMsg:
|
||||||
return m.remove(msg.ID), nil
|
return m.pop(), nil
|
||||||
}
|
}
|
||||||
|
return m.updateTop(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTop forwards msg to the topmost modal's content - the only one the
|
||||||
|
// user can interact with, everything beneath it being dimmed (see Render). A
|
||||||
|
// modal deeper in the stack is frozen until the ones above it close.
|
||||||
|
//
|
||||||
|
// This is what lets modal content be a real model: it gets the key presses,
|
||||||
|
// the ticks and the results of its own commands, and can report back to the
|
||||||
|
// rest of the program with a tea.Msg of its own.
|
||||||
|
func (m Model) updateTop(msg tea.Msg) (Model, tea.Cmd) {
|
||||||
|
i := len(m.modals) - 1
|
||||||
|
if i < 0 || m.modals[i].Content == nil {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.modals[i].Content, cmd = m.modals[i].Content.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
// Open reports whether at least one modal is currently shown - handy for a
|
// Open reports whether at least one modal is currently shown - handy for a
|
||||||
// host that wants to route key presses to the modal (e.g. esc to dismiss,
|
// host that wants to route key presses to the modal (e.g. esc to dismiss,
|
||||||
// enter to confirm) instead of its normal UI while one is open.
|
// enter to confirm) instead of its normal UI while one is open.
|
||||||
func (m Model) Open() bool { return len(m.modals) > 0 }
|
func (m Model) Open() bool { return len(m.modals) > 0 }
|
||||||
|
|
||||||
// TopID returns the ID of the topmost (currently interactive) modal, or ""
|
// show pushes a modal on top of the stack and returns its content's Init - a
|
||||||
// if none is open.
|
// modal's body starts the same way any other model does.
|
||||||
func (m Model) TopID() string {
|
func (m Model) show(mo Modal) (Model, tea.Cmd) {
|
||||||
if len(m.modals) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return m.modals[len(m.modals)-1].ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// show pushes or replaces (see WithID) a modal on top of the stack.
|
|
||||||
func (m Model) show(mo Modal) Model {
|
|
||||||
if mo.ID == "" {
|
|
||||||
mo.ID = fmt.Sprintf("modal-%d", m.nextID)
|
|
||||||
m.nextID++
|
|
||||||
}
|
|
||||||
for i, existing := range m.modals {
|
|
||||||
if existing.ID == mo.ID {
|
|
||||||
m.modals[i] = mo
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.modals = append(m.modals, mo)
|
m.modals = append(m.modals, mo)
|
||||||
return m
|
return m, initContent(mo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove closes the modal identified by id, or the topmost one if id is
|
// initContent is mo's content's Init, or nil for a modal without content.
|
||||||
// empty (see Close).
|
func initContent(mo Modal) tea.Cmd {
|
||||||
func (m Model) remove(id string) Model {
|
if mo.Content == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mo.Content.Init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pop closes the topmost modal (see Close).
|
||||||
|
func (m Model) pop() Model {
|
||||||
if len(m.modals) == 0 {
|
if len(m.modals) == 0 {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
if id == "" {
|
|
||||||
m.modals = m.modals[:len(m.modals)-1]
|
m.modals = m.modals[:len(m.modals)-1]
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
for i, mo := range m.modals {
|
|
||||||
if mo.ID == id {
|
|
||||||
m.modals = append(m.modals[:i], m.modals[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-4
@@ -79,8 +79,9 @@ func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
|
|||||||
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
|
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
|
||||||
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
|
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
|
||||||
|
|
||||||
inner := contentWidth(mo, maxW)
|
body := contentView(mo)
|
||||||
content := s.Content.Width(inner).Render(mo.Content)
|
inner := contentWidth(body, mo.Title, maxW)
|
||||||
|
content := s.Content.Width(inner).Render(body)
|
||||||
|
|
||||||
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
|
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
|
||||||
boxHeight := min(lipgloss.Height(content)+2, maxH)
|
boxHeight := min(lipgloss.Height(content)+2, maxH)
|
||||||
@@ -88,11 +89,22 @@ func (m Model) renderBox(mo Modal, s Styles, bgW, bgH int) string {
|
|||||||
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
|
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contentView is the modal body's rendered string, or "" for a modal without
|
||||||
|
// content. The box shrinks to fit whatever the content model draws, so a
|
||||||
|
// content that wants a specific size sets it on itself - the modal only ever
|
||||||
|
// sees the result.
|
||||||
|
func contentView(mo Modal) string {
|
||||||
|
if mo.Content == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return mo.Content.View().Content
|
||||||
|
}
|
||||||
|
|
||||||
// contentWidth is the modal's inner (border/padding excluded) width: its
|
// contentWidth is the modal's inner (border/padding excluded) width: its
|
||||||
// natural size (long enough for the widest line of title/content), capped
|
// natural size (long enough for the widest line of title/content), capped
|
||||||
// at maxWidth.
|
// at maxWidth.
|
||||||
func contentWidth(mo Modal, maxWidth int) int {
|
func contentWidth(body, title string, maxWidth int) int {
|
||||||
natural := max(naturalWidth(mo.Content), lipgloss.Width(mo.Title), 1)
|
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
|
||||||
capped := max(maxWidth-4, 1)
|
capped := max(maxWidth-4, 1)
|
||||||
return min(natural, capped)
|
return min(natural, capped)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ Toast-style notifications, triggered from anywhere in a bubbletea program via an
|
|||||||
`tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering
|
`tea.Msg` (`ShowMsg`/`Show`) rather than a direct reference to the `Model` that ends up rendering
|
||||||
them - standard Elm architecture, no IPC between processes.
|
them - standard Elm architecture, no IPC between processes.
|
||||||
|
|
||||||
It composites over an already-rendered string, so it has no dependency on
|
It composites over an already-rendered string, so it makes no assumption about how the host
|
||||||
`github.com/anotherhadi/ilovetui/layout`: the same `Model` works whether the host uses `layout`
|
builds that string: the same `Model` works whatever the host uses to lay out its main content.
|
||||||
for its main content or not.
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
"github.com/anotherhadi/ilovetui/notification"
|
"github.com/anotherhadi/ilovetui/notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
// rather than a direct reference to the Model that ends up rendering them.
|
// rather than a direct reference to the Model that ends up rendering them.
|
||||||
//
|
//
|
||||||
// It composites over an already-rendered string (see Model.Render), so it
|
// It composites over an already-rendered string (see Model.Render), so it
|
||||||
// has no dependency on github.com/anotherhadi/ilovetui/layout: the same
|
// makes no assumption at all about how the host builds that string: the same
|
||||||
// Model works whether the host uses layout for its main content or not (see
|
// Model works whatever the host uses to lay out its main content (see
|
||||||
// Model.Render and Model.View).
|
// Model.Render and Model.View).
|
||||||
package notification
|
package notification
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user