Change style package, new components, ...

Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-08-18 20:10:32 +02:00
parent 499d61bddf
commit 21b574eb6f
56 changed files with 2741 additions and 363 deletions
+14
View File
@@ -0,0 +1,14 @@
# notification
Toast notifications, triggered from anywhere in a bubbletea program via an exported `tea.Msg`
(`ShowMsg`/`Show`), not a direct reference to the `Model` that renders them. Composites over an
already-rendered string, so it makes no assumption about how the host builds that string.
- Four kinds: `Info`, `Success`, `Warning`, `Error`, each with its own `style.S` color preset.
- Auto-dismisses after `DefaultDuration` (3s) unless shown with `WithDuration(0)`, which makes it
sticky; a sticky toast needs `WithID` so `Dismiss(id)` can remove it later.
- Six anchors (`Top`, `TopLeft`, `TopRight`, `Bottom`, `BottomLeft`, `BottomRight`). Toasts stack
along the anchored edge, newest closest to it.
- `WithMaxWidth` caps growth; a toast narrower than the cap shrinks to fit instead.
See `examples/notification`.
+93
View File
@@ -0,0 +1,93 @@
package notification
import (
"fmt"
"time"
tea "charm.land/bubbletea/v2"
)
type Model struct {
toasts []Toast
nextID int
position Position
maxWidth int
styles Styles
}
type Option func(*Model)
func WithPosition(p Position) Option {
return func(m *Model) { m.position = p }
}
func WithMaxWidth(w int) Option {
return func(m *Model) { m.maxWidth = w }
}
func WithStyles(s Styles) Option {
return func(m *Model) { m.styles = s }
}
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
}
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
}
+48
View File
@@ -0,0 +1,48 @@
package notification
type Position int
const (
Top Position = iota
TopLeft
TopRight
Bottom
BottomLeft
BottomRight
)
const margin = 1
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
}
func (pos Position) anchoredTop() bool {
return pos == Top || pos == TopLeft || pos == TopRight
}
+127
View File
@@ -0,0 +1,127 @@
package notification
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
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)
compositor := lipgloss.NewCompositor(
lipgloss.NewLayer(background),
lipgloss.NewLayer(stack).X(x).Y(y).Z(1),
)
return compositor.Render()
}
func (m Model) View(width, height int) string {
return m.Render(blank(width, height))
}
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...)
}
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
}
}
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
boxHeight := lipgloss.Height(message) + 2
return style.RenderWithTitle(k.Border, k.Title.Render(t.Title), message, boxWidth, boxHeight)
}
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)
}
func effectiveMaxWidth(configured, bgWidth int) int {
fits := max(bgWidth-2*margin, 1)
if configured > 0 && configured < fits {
return configured
}
return fits
}
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")
}
+58
View File
@@ -0,0 +1,58 @@
package notification
import (
"image/color"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/ilovetui/style"
)
type KindStyle struct {
Border lipgloss.Style
Title lipgloss.Style
Message lipgloss.Style
}
type Styles struct {
Info KindStyle
Success KindStyle
Warning KindStyle
Error KindStyle
}
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),
}
}
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
}
}
+71
View File
@@ -0,0 +1,71 @@
package notification
import (
"time"
tea "charm.land/bubbletea/v2"
)
type Kind int
const (
Info Kind = iota
Success
Warning
Error
)
const DefaultDuration = 3 * time.Second
type Toast struct {
ID string
Title string
Message string
Kind Kind
Duration time.Duration
Style *KindStyle
}
type ToastOption func(*Toast)
func WithID(id string) ToastOption {
return func(t *Toast) { t.ID = id }
}
func WithDuration(d time.Duration) ToastOption {
return func(t *Toast) { t.Duration = d }
}
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
}
type ShowMsg struct{ Toast Toast }
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} }
}
type DismissMsg struct{ ID string }
func Dismiss(id string) tea.Cmd {
return func() tea.Msg { return DismissMsg{ID: id} }
}
type expireMsg struct{ id string }