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