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
+103
View File
@@ -0,0 +1,103 @@
# notification
Toast-style notifications, 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
them - 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/notification"
)
type model struct {
notif notification.Model
width, height int
}
func newModel() model {
return model{notif: notification.New()}
}
func (m model) Init() tea.Cmd { return m.notif.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
if msg.String() == "s" {
return m, notification.Show("Saved", "Config written to disk", notification.Success)
}
}
var cmd tea.Cmd
m.notif, cmd = m.notif.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
background := renderYourUI(m.width, m.height)
view := tea.NewView(m.notif.Render(background))
view.AltScreen = true
return view
}
```
Any component in the same bubbletea program can trigger a toast via `notification.Show`, without
holding a reference to the `notification.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, notification.Show("Saved", "Config written to disk", notification.Success)
return m, notification.Show("Sticky", "Stays until dismissed",
notification.Info, notification.WithID("sticky-demo"), notification.WithDuration(0))
return m, notification.Dismiss("sticky-demo")
```
Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own color preset (see Styling
below). By default a toast auto-dismisses after `notification.DefaultDuration` (3s);
`WithDuration(0)` makes it sticky - it stays until `Dismiss(id)` removes it, so a sticky toast
needs `WithID` to be dismissable later (an auto-generated id is never returned to the caller).
Showing again with the same id replaces the toast in place, resetting its position and timer,
instead of stacking a duplicate.
## Position and stacking
```go
n := notification.New(notification.WithPosition(notification.TopRight))
```
Six anchors: `Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight` - toasts always
hug an edge or corner, never the middle of the screen. Multiple toasts stack along the anchored
edge, newest closest to it; a stack that overflows the background's height clips the oldest
toasts first, so the newest ones stay visible.
## Styling
```go
n := notification.New(notification.WithMaxWidth(40), notification.WithStyles(myStyles))
return m, notification.Show("Title", "Message", notification.Success,
notification.WithToastStyle(oneOffStyle))
```
`WithMaxWidth` caps how wide a toast box can grow before its message wraps; a toast narrower than
the cap shrinks to fit its content instead of padding out to it. A toast can also never overflow
past the edge of whatever background it's rendered on, regardless of this cap. `WithStyles` sets
the default per-`Kind` look for every toast shown by this `Model`; `WithToastStyle` (a `Show`
option) overrides it for one toast alone. `DefaultStyles()` builds from `style.S`: `Info` uses
`Primary` (no dedicated "info" color in the theme), `Success`/`Warning`/`Error` use their matching
`style.S` alias.
## Examples
- `examples/notification` - all four kinds, a sticky toast with manual dismiss, cycling through
all six positions.
+115
View File
@@ -0,0 +1,115 @@
// Package notification renders toast-style notifications, 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 them.
//
// 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 notification
import (
"fmt"
"time"
tea "charm.land/bubbletea/v2"
)
// Model holds the currently visible toasts and the rendering config
// (position, max width, per-Kind styles) they share. Build one with New.
type Model struct {
toasts []Toast
nextID int
position Position
maxWidth int
styles Styles
}
// Option configures a Model at construction. See WithPosition, WithMaxWidth,
// WithStyles.
type Option func(*Model)
// WithPosition sets which edge/corner the toast stack anchors to. TopRight
// by default.
func WithPosition(p Position) Option {
return func(m *Model) { m.position = p }
}
// WithMaxWidth caps how wide a toast box can grow before its message
// wraps. A toast 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 unlimited.
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
// WithStyles overrides the default per-Kind styles (see DefaultStyles).
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
// New builds a Model. Defaults: TopRight, a 40-cell max width, DefaultStyles.
func New(opts ...Option) Model {
m := Model{
position: TopRight,
maxWidth: 40,
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.Toast)
case DismissMsg:
return m.remove(msg.ID), nil
case expireMsg:
return m.remove(msg.id), nil
}
return m, nil
}
// show adds or replaces (see WithID) a toast, and schedules its expiry via
// tea.Tick if it isn't sticky (Duration <= 0).
func (m Model) show(t Toast) (Model, tea.Cmd) {
if t.ID == "" {
t.ID = fmt.Sprintf("toast-%d", m.nextID)
m.nextID++
}
replaced := false
for i, existing := range m.toasts {
if existing.ID == t.ID {
m.toasts[i] = t
replaced = true
break
}
}
if !replaced {
m.toasts = append(m.toasts, t)
}
if t.Duration <= 0 {
return m, nil
}
id := t.ID
return m, tea.Tick(t.Duration, func(time.Time) tea.Msg {
return expireMsg{id: id}
})
}
func (m Model) remove(id string) Model {
for i, t := range m.toasts {
if t.ID == id {
m.toasts = append(m.toasts[:i], m.toasts[i+1:]...)
break
}
}
return m
}
+58
View File
@@ -0,0 +1,58 @@
package notification
// Position anchors the toast stack to one of six spots on the rendered
// background. There's no center/middle variant: toasts always hug an edge or
// a corner, never the middle of the screen.
type Position int
const (
Top Position = iota
TopLeft
TopRight
Bottom
BottomLeft
BottomRight
)
// margin is the fixed gap, in cells, kept between the toast stack and the
// edge(s) of the background it's anchored to.
const margin = 1
// placement resolves the top-left (x, y) coordinate to draw a stack of size
// (sw, sh) at, given a background of size (w, h) and the anchor position.
func placement(pos Position, w, h, sw, sh int) (x, y int) {
switch pos {
case Top:
x = (w - sw) / 2
y = margin
case TopLeft:
x = margin
y = margin
case TopRight:
x = w - sw - margin
y = margin
case Bottom:
x = (w - sw) / 2
y = h - sh - margin
case BottomLeft:
x = margin
y = h - sh - margin
case BottomRight:
x = w - sw - margin
y = h - sh - margin
}
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
return x, y
}
// anchoredTop reports whether pos hugs the top edge, which decides both the
// stacking order (see Model.orderedToasts) and which side of an overflowing
// stack gets clipped (see clipToHeight).
func (pos Position) anchoredTop() bool {
return pos == Top || pos == TopLeft || pos == TopRight
}
+167
View File
@@ -0,0 +1,167 @@
package notification
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// Render composites the current toasts on top of background (already
// rendered, e.g. layout.Model.View() or any other component's View()) and
// returns the result. background is returned unchanged whenever there's
// nothing to draw (no toasts, or a background with no measurable size).
//
// This is what makes notification work identically with or without layout:
// the host just wraps whatever it would otherwise return from its own
// View() with this call.
func (m Model) Render(background string) string {
if len(m.toasts) == 0 {
return background
}
w, h := lipgloss.Width(background), lipgloss.Height(background)
if w <= 0 || h <= 0 {
return background
}
stack := clipToHeight(m.renderStack(effectiveMaxWidth(m.maxWidth, w)), h-2*margin, m.position.anchoredTop())
if stack == "" {
return background
}
sw, sh := lipgloss.Width(stack), lipgloss.Height(stack)
x, y := placement(m.position, w, h, sw, sh)
// Canvas.Compose(layer) alone ignores the layer's X/Y and draws it across
// the canvas's whole bounds, not just its own footprint - that's what
// made the toast layer blank out the entire background instead of
// floating over it. Compositor is what actually resolves each layer's
// absolute bounds (background at 0,0, the stack at x,y) before drawing
// each one only within its own area.
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(background),
lipgloss.NewLayer(stack).X(x).Y(y).Z(1),
)
return compositor.Render()
}
// View is a convenience for a pane whose sole purpose is showing toasts (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))
}
// renderStack stacks every visible toast into one block, newest closest to
// the anchored edge (see Position.anchoredTop), separated by a blank line,
// and aligned so the edge the stack anchors to stays flush across toasts of
// different widths.
func (m Model) renderStack(maxWidth int) string {
ordered := m.orderedToasts()
parts := make([]string, 0, len(ordered)*2-1)
for i, t := range ordered {
if i > 0 {
parts = append(parts, "")
}
parts = append(parts, m.renderToast(t, maxWidth))
}
return lipgloss.JoinVertical(stackAlign(m.position), parts...)
}
// orderedToasts returns the toasts in the order they should stack, newest
// nearest the anchored edge: reversed (newest first) for a top anchor,
// insertion order (oldest first, newest last) for a bottom anchor.
func (m Model) orderedToasts() []Toast {
if !m.position.anchoredTop() {
return m.toasts
}
ordered := make([]Toast, len(m.toasts))
for i, t := range m.toasts {
ordered[len(m.toasts)-1-i] = t
}
return ordered
}
func stackAlign(pos Position) lipgloss.Position {
switch pos {
case TopLeft, BottomLeft:
return lipgloss.Left
case TopRight, BottomRight:
return lipgloss.Right
default:
return lipgloss.Center
}
}
// renderToast draws a single toast as a box with its title embedded in the
// top border (style.RenderWithTitle), shrunk to fit its content up to
// maxWidth.
func (m Model) renderToast(t Toast, maxWidth int) string {
k := m.styles.forKind(t)
inner := contentWidth(t, maxWidth)
message := k.Message.Width(inner).Render(t.Message)
boxWidth := inner + 4 // border (2) + Padding(0, 1) (2)
boxHeight := lipgloss.Height(message) + 2
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
}
// contentWidth is the toast's inner (border/padding excluded) width: its
// natural size (long enough for the wider of title/message on one line),
// capped at maxWidth if positive.
func contentWidth(t Toast, maxWidth int) int {
natural := max(lipgloss.Width(t.Title), lipgloss.Width(t.Message), 1)
if maxWidth <= 0 {
return natural
}
capped := max(maxWidth-4, 1)
return min(natural, capped)
}
// effectiveMaxWidth resolves the cap actually used to render a toast:
// configured (Model.maxWidth, 0 = unlimited) narrowed down to whatever
// actually fits the background it's about to be drawn on, so a toast can
// never overflow past the edge of the background - or the terminal, when
// the background is a full-screen View() - regardless of how WithMaxWidth
// was set. bgWidth is background's own width, already measured by Render.
func effectiveMaxWidth(configured, bgWidth int) int {
fits := max(bgWidth-2*margin, 1)
if configured > 0 && configured < fits {
return configured
}
return fits
}
// clipToHeight trims stack to at most maxHeight lines when it overflows,
// keeping the lines nearest the anchored edge (top rows for a top anchor,
// bottom rows for a bottom anchor) so the newest toasts - always nearest
// that edge, see orderedToasts - are the ones that stay visible.
func clipToHeight(stack string, maxHeight int, anchoredTop bool) string {
lines := strings.Split(stack, "\n")
if len(lines) <= maxHeight {
return stack
}
if maxHeight <= 0 {
return ""
}
if anchoredTop {
lines = lines[:maxHeight]
} else {
lines = lines[len(lines)-maxHeight:]
}
return strings.Join(lines, "\n")
}
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")
}
+73
View File
@@ -0,0 +1,73 @@
package notification
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
// KindStyle is the set of lipgloss styles used to render one toast: Border
// carries the box's border (shape + color, no size), Title and Message color
// the two pieces of text drawn inside it. Building a value directly (rather
// than through a constructor) is the intended way to hand WithToastStyle a
// custom, per-toast look.
type KindStyle struct {
Border lipgloss.Style
Title lipgloss.Style
Message lipgloss.Style
}
// Styles maps each Kind to the KindStyle used to render it. Build one with
// DefaultStyles and tweak individual fields, or construct one from scratch
// for a fully custom palette across all kinds.
type Styles struct {
Info KindStyle
Success KindStyle
Warning KindStyle
Error KindStyle
}
// DefaultStyles builds a Styles from style.S: Info uses the theme's primary
// accent (style.S has no dedicated "info" color, Primary already fills that
// neutral-accent role elsewhere in this repo), Success/Warning/Error use
// their matching style.S alias.
func DefaultStyles() Styles {
return Styles{
Info: kindStyle(style.S.Primary),
Success: kindStyle(style.S.Success),
Warning: kindStyle(style.S.Warning),
Error: kindStyle(style.S.Error),
}
}
func kindStyle(c color.Color) KindStyle {
return KindStyle{
Border: lipgloss.NewStyle().
Border(style.S.BorderType).
BorderForeground(c).
Padding(0, 1),
Title: lipgloss.NewStyle().Bold(true).Foreground(c),
Message: lipgloss.NewStyle().Foreground(style.S.Text),
}
}
// forKind resolves the KindStyle to render t with: its own Style override if
// set, otherwise s's preset for t.Kind (falling back to Info for an
// out-of-range Kind).
func (s Styles) forKind(t Toast) KindStyle {
if t.Style != nil {
return *t.Style
}
switch t.Kind {
case Success:
return s.Success
case Warning:
return s.Warning
case Error:
return s.Error
default:
return s.Info
}
}
+107
View File
@@ -0,0 +1,107 @@
package notification
import (
"time"
tea "charm.land/bubbletea/v2"
)
// Kind picks which of Styles' presets a toast renders with, unless
// overridden per-toast via WithToastStyle.
type Kind int
const (
Info Kind = iota
Success
Warning
Error
)
// DefaultDuration is how long a toast stays visible when WithDuration isn't
// used. Show has no reference to a Model (see ShowMsg's doc comment), so this
// lives as a package constant rather than a Model-level default.
const DefaultDuration = 3 * time.Second
// Toast is one notification. Build it via Show's opts rather than a literal:
// ID and Duration both get defaults (see WithID, DefaultDuration) that a bare
// literal would silently skip.
type Toast struct {
ID string
Title string
Message string
Kind Kind
// Duration is how long the toast stays up before auto-dismissing. 0
// means sticky: it stays until DismissMsg/Dismiss(ID) removes it.
Duration time.Duration
// Style, if non-nil, overrides the Model's Kind-based preset for this
// toast alone.
Style *KindStyle
}
// ToastOption configures a Toast built by Show.
type ToastOption func(*Toast)
// WithID gives the toast a stable id, so a later Show reusing the same id
// replaces it in place (resetting its position and timer) instead of
// stacking a duplicate, and so it can be targeted by Dismiss.
func WithID(id string) ToastOption {
return func(t *Toast) { t.ID = id }
}
// WithDuration overrides DefaultDuration. 0 makes the toast sticky: it never
// auto-dismisses, only Dismiss(ID) removes it.
func WithDuration(d time.Duration) ToastOption {
return func(t *Toast) { t.Duration = d }
}
// WithToastStyle overrides the Model's Kind-based preset for this toast
// alone, for a one-off custom look instead of the type-based theme.
func WithToastStyle(s KindStyle) ToastOption {
return func(t *Toast) { t.Style = &s }
}
func newToast(title, message string, kind Kind, opts ...ToastOption) Toast {
t := Toast{
Title: title,
Message: message,
Kind: kind,
Duration: DefaultDuration,
}
for _, opt := range opts {
opt(&t)
}
return t
}
// ShowMsg tells a notification.Model to display Toast. Any component in the
// same bubbletea program can trigger one via Show, without holding a
// reference to the notification.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{ Toast Toast }
// Show returns a tea.Cmd that shows a new toast of the given kind. Call it
// from any component's Update:
//
// return m, notification.Show("Saved", "Config written to disk", notification.Success)
func Show(title, message string, kind Kind, opts ...ToastOption) tea.Cmd {
t := newToast(title, message, kind, opts...)
return func() tea.Msg { return ShowMsg{Toast: t} }
}
// DismissMsg removes the toast identified by ID, whether it's sticky or
// mid-countdown. A no-op if ID isn't currently shown (already expired, or
// never had an explicit id in the first place - see WithID).
type DismissMsg struct{ ID string }
// Dismiss returns a tea.Cmd that removes the toast identified by id. Only
// useful for toasts shown with WithID, since an auto-generated id is never
// exposed back to the caller.
func Dismiss(id string) tea.Cmd {
return func() tea.Msg { return DismissMsg{ID: id} }
}
// expireMsg fires once a toast's Duration has elapsed, scheduled by
// Model.show via tea.Tick. Unexported: nothing outside the package should
// construct or match on it directly, that's what DismissMsg is for.
type expireMsg struct{ id string }