mirror of
https://github.com/anotherhadi/ilovetui.git
synced 2026-08-22 04:25:49 +02:00
Change style package, new components, ...
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user