mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-22 12:35:48 +02:00
Change style package, new components, ...
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 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 it otherwise
|
||||
mirrors closely: same `tea.Msg`-triggered stack (`ShowMsg`/`Show`), same composite-over-a-string
|
||||
`Render`, same rule that content is a `tea.Model`.
|
||||
|
||||
- `WithSide(Left|Right)` and `WithWidth` are per-drawer `Show` options; width otherwise shrinks to
|
||||
fit content, capped by the `Model`'s `WithMaxWidth`.
|
||||
- The stack is a plain LIFO. Only the topmost drawer is updated; `Close()` closes it.
|
||||
- `View(width, height)` renders on a blank background of that size, for use as a standalone pane.
|
||||
|
||||
See `examples/drawer`.
|
||||
@@ -0,0 +1,61 @@
|
||||
package drawer
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Side int
|
||||
|
||||
const (
|
||||
Left Side = iota
|
||||
Right
|
||||
)
|
||||
|
||||
type Drawer struct {
|
||||
Title string
|
||||
Content tea.Model
|
||||
Side Side
|
||||
Width int
|
||||
Style *Styles
|
||||
}
|
||||
|
||||
type DrawerOption func(*Drawer)
|
||||
|
||||
func WithSide(s Side) DrawerOption {
|
||||
return func(d *Drawer) { d.Side = s }
|
||||
}
|
||||
|
||||
func WithWidth(w int) DrawerOption {
|
||||
return func(d *Drawer) { d.Width = w }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type ShowMsg struct{ Drawer Drawer }
|
||||
|
||||
func Show(title string, content tea.Model, opts ...DrawerOption) tea.Cmd {
|
||||
d := newDrawer(title, content, opts...)
|
||||
return func() tea.Msg { return ShowMsg{Drawer: d} }
|
||||
}
|
||||
|
||||
type DismissMsg struct{}
|
||||
|
||||
func Close() tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{} }
|
||||
}
|
||||
|
||||
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)) }
|
||||
|
||||
func Text(s string) tea.Model { return text(s) }
|
||||
@@ -0,0 +1,75 @@
|
||||
package drawer
|
||||
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
|
||||
type Model struct {
|
||||
drawers []Drawer
|
||||
nextID int
|
||||
maxWidth int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
type Option func(*Model)
|
||||
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
func WithStyles(s Styles) Option {
|
||||
return func(m *Model) { m.styles = s }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (m Model) Open() bool { return len(m.drawers) > 0 }
|
||||
|
||||
func (m Model) show(d Drawer) (Model, tea.Cmd) {
|
||||
m.drawers = append(m.drawers, d)
|
||||
return m, initContent(d)
|
||||
}
|
||||
|
||||
func initContent(d Drawer) tea.Cmd {
|
||||
if d.Content == nil {
|
||||
return nil
|
||||
}
|
||||
return d.Content.Init()
|
||||
}
|
||||
|
||||
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,119 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (m Model) View(width, height int) string {
|
||||
return m.Render(blank(width, height))
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func dim(s string, c color.Color) string {
|
||||
return lipgloss.NewStyle().Foreground(c).Render(ansi.Strip(s))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return style.RenderWithTitle(s.Border, s.Title.Render(d.Title), content, boxWidth, bgH)
|
||||
}
|
||||
|
||||
func contentView(d Drawer) string {
|
||||
if d.Content == nil {
|
||||
return ""
|
||||
}
|
||||
return d.Content.View().Content
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
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,25 @@
|
||||
package drawer
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
type Styles struct {
|
||||
Border lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Content lipgloss.Style
|
||||
DimColor color.Color
|
||||
}
|
||||
|
||||
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