Change style package, new components, ...

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-18 20:10:32 +02:00
parent 499d61bddf
commit 21b574eb6f
56 changed files with 2741 additions and 363 deletions
+15
View File
@@ -0,0 +1,15 @@
# modal
A centered popup box on top of a dimmed background, triggered from anywhere in a bubbletea program
via an exported `tea.Msg` (`ShowMsg`/`Show`), not a direct reference to the `Model` that renders it.
Composites over an already-rendered string, so it makes no assumption about how the host builds
that string.
- A modal's content is a `tea.Model`, not a string: it gets `Init`/`Update` while it's on top, and
can report back with its own `tea.Msg` (see `modal.Text` for a plain, non-interactive content).
- The stack is a plain LIFO with no identity. Only the topmost modal is updated; `Close()` always
closes it.
- Showing a second modal while one is open pushes it on top, nesting confirmations naturally.
- `WithMaxWidth`/`WithMaxHeight` cap growth; a modal narrower than the cap shrinks to fit instead.
See `examples/modal`.
+46
View File
@@ -0,0 +1,46 @@
package modal
import tea "charm.land/bubbletea/v2"
type Modal struct {
Title string
Content tea.Model
Style *Styles
}
type ModalOption func(*Modal)
func WithModalStyle(s Styles) ModalOption {
return func(mo *Modal) { mo.Style = &s }
}
func newModal(title string, content tea.Model, opts ...ModalOption) Modal {
mo := Modal{Title: title, Content: content}
for _, opt := range opts {
opt(&mo)
}
return mo
}
type ShowMsg struct{ Modal Modal }
func Show(title string, content tea.Model, opts ...ModalOption) tea.Cmd {
mo := newModal(title, content, opts...)
return func() tea.Msg { return ShowMsg{Modal: mo} }
}
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) }
+80
View File
@@ -0,0 +1,80 @@
package modal
import tea "charm.land/bubbletea/v2"
type Model struct {
modals []Modal
maxWidth int
maxHeight int
styles Styles
}
type Option func(*Model)
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
func WithMaxHeight(h int) Option {
return func(m *Model) { m.maxHeight = h }
}
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
func New(opts ...Option) Model {
m := Model{
maxWidth: 60,
maxHeight: 20,
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.Modal)
case DismissMsg:
return m.pop(), nil
}
return m.updateTop(msg)
}
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
}
var cmd tea.Cmd
m.modals[i].Content, cmd = m.modals[i].Content.Update(msg)
return m, cmd
}
func (m Model) Open() bool { return len(m.modals) > 0 }
func (m Model) show(mo Modal) (Model, tea.Cmd) {
m.modals = append(m.modals, mo)
return m, initContent(mo)
}
func initContent(mo Modal) tea.Cmd {
if mo.Content == nil {
return nil
}
return mo.Content.Init()
}
func (m Model) pop() Model {
if len(m.modals) == 0 {
return m
}
m.modals = m.modals[:len(m.modals)-1]
return m
}
+113
View File
@@ -0,0 +1,113 @@
package modal
import (
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/anotherhadi/ilovetui/style"
)
const margin = 2
func (m Model) Render(background string) string {
if len(m.modals) == 0 {
return background
}
result := background
for _, mo := range m.modals {
w, h := lipgloss.Width(result), lipgloss.Height(result)
if w <= 0 || h <= 0 {
return result
}
result = m.renderOne(mo, result, w, h)
}
return result
}
func (m Model) View(width, height int) string {
return m.Render(blank(width, height))
}
func (m Model) renderOne(mo Modal, background string, w, h int) string {
s := m.styles
if mo.Style != nil {
s = *mo.Style
}
box := m.renderBox(mo, s, w, h)
bw, bh := lipgloss.Width(box), lipgloss.Height(box)
x, y := max((w-bw)/2, 0), max((h-bh)/2, 0)
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(dim(background, s.DimColor)),
lipgloss.NewLayer(box).X(x).Y(y).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(mo Modal, s Styles, bgW, bgH int) string {
maxW := effectiveMax(m.maxWidth, bgW-2*margin)
maxH := effectiveMax(m.maxHeight, bgH-2*margin)
body := contentView(mo)
inner := contentWidth(body, mo.Title, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4
boxHeight := min(lipgloss.Height(content)+2, maxH)
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
}
func contentView(mo Modal) string {
if mo.Content == nil {
return ""
}
return mo.Content.View().Content
}
func contentWidth(body, title string, maxWidth int) int {
natural := max(naturalWidth(body), lipgloss.Width(title), 1)
capped := max(maxWidth-4, 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")
}
+25
View File
@@ -0,0 +1,25 @@
package modal
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,
}
}