mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user