mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-21 12:05:49 +02:00
init layout, notifications, tabs & more
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
||||
# 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`) 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.
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/anotherhadi/ilovetui/modal"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
m modal.Model
|
||||
width, height int
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{m: modal.New()}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd { return m.m.Init() }
|
||||
|
||||
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")
|
||||
}
|
||||
if msg.String() == "esc" && m.m.Open() {
|
||||
return m, modal.Close()
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.m, cmd = m.m.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
background := renderYourUI(m.width, m.height)
|
||||
view := tea.NewView(m.m.Render(background))
|
||||
view.AltScreen = true
|
||||
return view
|
||||
}
|
||||
```
|
||||
|
||||
Any component in the same bubbletea program can trigger a modal via `modal.Show`, without holding
|
||||
a reference to the `modal.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.
|
||||
|
||||
## Showing and dismissing
|
||||
|
||||
```go
|
||||
return m, modal.Show("Delete file?", "This can't be undone.", modal.WithID("confirm"))
|
||||
|
||||
return m, modal.Dismiss("confirm") // close a specific modal by id
|
||||
return m, modal.Close() // close whichever modal is on top, whatever its id
|
||||
```
|
||||
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
beneath, still in full color. This is what lets a "delete?" confirmation open a nested "really
|
||||
sure?" modal without any special-casing.
|
||||
|
||||
## Styling
|
||||
|
||||
```go
|
||||
m := modal.New(modal.WithMaxWidth(60), modal.WithMaxHeight(20), modal.WithStyles(myStyles))
|
||||
|
||||
return m, modal.Show("Title", "Message", modal.WithModalStyle(oneOffStyles))
|
||||
```
|
||||
|
||||
`WithMaxWidth`/`WithMaxHeight` cap how large a modal box can grow before wrapping/truncating; a
|
||||
modal narrower than the cap shrinks to fit its content instead of padding out to it. A modal can
|
||||
also never overflow past the edge of whatever background it's rendered on, regardless of these
|
||||
caps. `WithStyles` sets the default look for every modal shown by this `Model`; `WithModalStyle`
|
||||
(a `Show` option) overrides it for one modal alone. `DefaultStyles()` builds from `style.S`: the
|
||||
box borrows `PanelFocused`'s border (the modal is what has focus while it's open), the dim color
|
||||
reuses `Subtle`.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/modal` - open/dismiss, nested modals, styled from theme colors.
|
||||
@@ -0,0 +1,70 @@
|
||||
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.
|
||||
type Modal struct {
|
||||
ID string
|
||||
Title string
|
||||
Content string
|
||||
// Style, if non-nil, overrides the Model's default Styles for this
|
||||
// modal alone.
|
||||
Style *Styles
|
||||
}
|
||||
|
||||
// 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 {
|
||||
mo := Modal{Title: title, Content: content}
|
||||
for _, opt := range opts {
|
||||
opt(&mo)
|
||||
}
|
||||
return mo
|
||||
}
|
||||
|
||||
// ShowMsg tells a modal.Model to display Modal, 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 modal.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{ Modal Modal }
|
||||
|
||||
// Show returns a tea.Cmd that opens a new modal on top of the stack:
|
||||
//
|
||||
// return m, modal.Show("Delete file?", "This can't be undone.")
|
||||
func Show(title, content string, 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 }
|
||||
|
||||
// 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.
|
||||
func Close() tea.Cmd {
|
||||
return func() tea.Msg { return DismissMsg{} }
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
// Package modal renders a centered popup box on top of a dimmed background,
|
||||
// triggered from anywhere in a bubbletea program via an exported tea.Msg
|
||||
// (see ShowMsg/Show) rather than a direct reference to the Model that ends
|
||||
// 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
|
||||
// Model.Render and Model.View).
|
||||
package modal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
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
|
||||
// modal - dimmed, see Render) and the rendering config (max size, styles)
|
||||
// they share. Build one with New.
|
||||
type Model struct {
|
||||
modals []Modal
|
||||
nextID int
|
||||
maxWidth int
|
||||
maxHeight int
|
||||
styles Styles
|
||||
}
|
||||
|
||||
// Option configures a Model at construction. See WithMaxWidth,
|
||||
// WithMaxHeight, WithStyles.
|
||||
type Option func(*Model)
|
||||
|
||||
// WithMaxWidth caps how wide a modal box can grow before its content wraps.
|
||||
// A modal narrower than this shrinks to fit its content instead of padding
|
||||
// out to the cap. 0 (also the zero-value Model's default without New) means
|
||||
// only the background's own size caps it.
|
||||
func WithMaxWidth(w int) Option {
|
||||
return func(m *Model) { m.maxWidth = w }
|
||||
}
|
||||
|
||||
// WithMaxHeight caps how tall a modal box can grow before its content is
|
||||
// truncated. 0 means only the background's own size caps it.
|
||||
func WithMaxHeight(h int) Option {
|
||||
return func(m *Model) { m.maxHeight = h }
|
||||
}
|
||||
|
||||
// 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 60x20 max size, DefaultStyles.
|
||||
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), nil
|
||||
case DismissMsg:
|
||||
return m.remove(msg.ID), nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
m.modals = append(m.modals, mo)
|
||||
return m
|
||||
}
|
||||
|
||||
// remove closes the modal identified by id, or the topmost one if id is
|
||||
// empty (see Close).
|
||||
func (m Model) remove(id string) 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
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package modal
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/anotherhadi/ilovetui/style"
|
||||
)
|
||||
|
||||
// margin is the fixed gap, in cells, kept between a modal box and the edges
|
||||
// of the background it's centered on.
|
||||
const margin = 2
|
||||
|
||||
// Render draws every open modal (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 modal in the stack first flattens
|
||||
// whatever came before it - background plus any earlier modal - to a single
|
||||
// flat DimColor (see dim), then draws its own box centered on top, so
|
||||
// nesting a second modal 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.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
|
||||
}
|
||||
|
||||
// View is a convenience for a pane whose sole purpose is showing modals
|
||||
// (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 mo's box centered on top of it.
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 mo as a bordered, title-embedded box (style.RenderWithTitle),
|
||||
// shrunk to fit its content, capped by the Model's configured max size and by
|
||||
// whatever actually fits inside a bgW x bgH background.
|
||||
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)
|
||||
|
||||
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
|
||||
boxHeight := min(lipgloss.Height(content)+2, maxH)
|
||||
|
||||
return style.RenderWithTitle(s.Border, s.Title.Render(mo.Title), content, boxWidth, boxHeight)
|
||||
}
|
||||
|
||||
// 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)
|
||||
capped := max(maxWidth-4, 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 one axis: configured
|
||||
// (0 = unlimited) narrowed down to fits, whatever actually fits the
|
||||
// background - a modal can never overflow past the edge of the background,
|
||||
// or the terminal, when the background is a full-screen View(), regardless
|
||||
// of how WithMaxWidth/WithMaxHeight 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,36 @@
|
||||
package modal
|
||||
|
||||
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
|
||||
// modal 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 modal 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 modal 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) - 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