Signed-off-by: Hadi <hadi@example.fr>
This commit is contained in:
Hadi
2026-08-18 16:15:03 +02:00
parent 5f99fa7521
commit aa6496901f
48 changed files with 2001 additions and 3626 deletions
+54 -18
View File
@@ -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
`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
`github.com/anotherhadi/ilovetui/layout`: the same `Model` works whether the host uses `layout`
for its main content or not.
It composites over an already-rendered string, so it makes no assumption about how the host
builds that string: the same `Model` works whatever the host uses to lay out its main content.
## Quick start
@@ -30,7 +29,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
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() {
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`),
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
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
return m, modal.Close() // close whichever modal is on top, whatever its id
func (c confirm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
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.
- `modal.TopID()` returns the id of the topmost (currently interactive) modal, or `""` if none is
open - useful to tell which modal a generic key like `enter` should act on.
- A modal shown without `WithID` gets an auto-generated id that's never returned to the caller -
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.
wants to route key presses to the modal 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 modal, which is what keeps
the stack shallow without any bookkeeping.
- A content model closes its own modal by returning `modal.Close()`, since it only ever runs while
it is the topmost one.
## Stacking
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
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
```go
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
+38 -28
View File
@@ -2,12 +2,13 @@ package modal
import tea "charm.land/bubbletea/v2"
// Modal is one popup. Build it via Show's opts rather than a literal: ID
// gets a default (see WithID) that a bare literal would silently skip.
// Modal is one popup on the stack.
type Modal struct {
ID string
Title string
Content string
Title 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
// modal alone.
Style *Styles
@@ -16,20 +17,13 @@ type Modal struct {
// ModalOption configures a Modal built by Show.
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,
// for a one-off custom look instead of the shared theme.
func WithModalStyle(s Styles) ModalOption {
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}
for _, opt := range opts {
opt(&mo)
@@ -44,27 +38,43 @@ func newModal(title, content string, opts ...ModalOption) Modal {
// produces, same as any other child model.
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.")
func Show(title, content string, opts ...ModalOption) tea.Cmd {
// return m, modal.Show("Delete file?", modal.Text("This can't be undone."))
// 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...)
return func() tea.Msg { return ShowMsg{Modal: mo} }
}
// DismissMsg closes the modal identified by ID, or the topmost modal if ID
// is empty.
type DismissMsg struct{ ID string }
// DismissMsg closes the topmost modal. The stack is a plain LIFO: a modal is
// closed by being on top, never by being named.
type DismissMsg struct{}
// Dismiss returns a tea.Cmd that closes the modal identified by id. Only
// useful for modals shown with WithID.
func Dismiss(id string) tea.Cmd {
return func() tea.Msg { return DismissMsg{ID: id} }
}
// 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.
// Close returns a tea.Cmd that closes the topmost modal - which is also the
// only one that can act (see Model.Update), so a content model closes itself
// by returning it:
//
// return c, modal.Close()
func Close() tea.Cmd {
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) }
+43 -46
View File
@@ -4,16 +4,17 @@
// up rendering it.
//
// It composites over an already-rendered string (see Model.Render), so it
// has no dependency on github.com/anotherhadi/ilovetui/layout: the same
// Model works whether the host uses layout for its main content or not (see
// makes no assumption at all about how the host builds that string: the same
// Model works whatever the host uses to lay out its main content (see
// 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
import (
"fmt"
tea "charm.land/bubbletea/v2"
)
import tea "charm.land/bubbletea/v2"
// Model holds the currently open modals (a stack: the most recently shown
// is drawn on top, everything beneath it - the background and any earlier
@@ -21,7 +22,6 @@ import (
// they share. Build one with New.
type Model struct {
modals []Modal
nextID int
maxWidth int
maxHeight int
styles Styles
@@ -68,11 +68,28 @@ 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), nil
return m.show(msg.Modal)
case DismissMsg:
return m.remove(msg.ID), nil
return m.pop(), nil
}
return m, 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
}
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
@@ -80,46 +97,26 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// enter to confirm) instead of its normal UI while one is open.
func (m Model) Open() bool { return len(m.modals) > 0 }
// TopID returns the ID of the topmost (currently interactive) modal, or ""
// if none is open.
func (m Model) TopID() string {
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
}
}
// show pushes a modal on top of the stack and returns its content's Init - a
// modal's body starts the same way any other model does.
func (m Model) show(mo Modal) (Model, tea.Cmd) {
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
// empty (see Close).
func (m Model) remove(id string) Model {
// initContent is mo's content's Init, or nil for a modal without content.
func initContent(mo Modal) tea.Cmd {
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 {
return m
}
if id == "" {
m.modals = m.modals[:len(m.modals)-1]
return m
}
for i, mo := range m.modals {
if mo.ID == id {
m.modals = append(m.modals[:i], m.modals[i+1:]...)
break
}
}
m.modals = m.modals[:len(m.modals)-1]
return m
}
+16 -4
View File
@@ -79,8 +79,9 @@ 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)
inner := contentWidth(mo, maxW)
content := s.Content.Width(inner).Render(mo.Content)
body := contentView(mo)
inner := contentWidth(body, mo.Title, maxW)
content := s.Content.Width(inner).Render(body)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
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)
}
// 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
// natural size (long enough for the widest line of title/content), capped
// at maxWidth.
func contentWidth(mo Modal, maxWidth int) int {
natural := max(naturalWidth(mo.Content), lipgloss.Width(mo.Title), 1)
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)
}