init layout, notifications, tabs & more

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-17 22:09:32 +02:00
parent f3a1877832
commit 5f99fa7521
68 changed files with 6375 additions and 330 deletions
+101
View File
@@ -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.
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
+36
View File
@@ -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,
}
}