Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 92 additions & 66 deletions pkg/tui/components/notification/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
)

const (
closeButton = " [x]"
defaultDuration = 3 * time.Second
notificationPadding = 2
maxNotificationWidth = 80 // Maximum width to prevent covering too much screen
Expand All @@ -30,6 +31,25 @@ const (
TypeError
)

// persistent returns true for notification types that stay until manually dismissed.
func (t Type) persistent() bool {
return t == TypeWarning || t == TypeError
}

// style returns the lipgloss style for this notification type.
func (t Type) style() lipgloss.Style {
switch t {
case TypeError:
return styles.NotificationErrorStyle
case TypeWarning:
return styles.NotificationWarningStyle
case TypeInfo:
return styles.NotificationInfoStyle
default:
return styles.NotificationStyle
}
}

type ShowMsg struct {
Text string
Type Type // Defaults to TypeSuccess for backward compatibility
Expand All @@ -40,39 +60,41 @@ type HideMsg struct {
}

func SuccessCmd(text string) tea.Cmd {
return core.CmdHandler(ShowMsg{
Text: text,
Type: TypeSuccess,
})
return core.CmdHandler(ShowMsg{Text: text, Type: TypeSuccess})
}

func WarningCmd(text string) tea.Cmd {
return core.CmdHandler(ShowMsg{
Text: text,
Type: TypeWarning,
})
return core.CmdHandler(ShowMsg{Text: text, Type: TypeWarning})
}

func InfoCmd(text string) tea.Cmd {
return core.CmdHandler(ShowMsg{
Text: text,
Type: TypeInfo,
})
return core.CmdHandler(ShowMsg{Text: text, Type: TypeInfo})
}

func ErrorCmd(text string) tea.Cmd {
return core.CmdHandler(ShowMsg{
Text: text,
Type: TypeError,
})
return core.CmdHandler(ShowMsg{Text: text, Type: TypeError})
}

// notificationItem represents a single notification
type notificationItem struct {
ID uint64
Text string
Type Type
TimerCmd tea.Cmd
ID uint64
Text string
Type Type
}

// render returns the styled view string for this notification item,
// including a close button for persistent notifications.
func (item notificationItem) render(maxWidth int) string {
text := item.Text
if item.Type.persistent() {
text += closeButton
}

style := item.Type.style()
if lipgloss.Width(text) > maxWidth {
return style.Width(maxWidth).Render(text)
}
return style.Render(text)
}

// Manager represents a notification manager that displays
Expand Down Expand Up @@ -110,19 +132,17 @@ func (n *Manager) Update(msg tea.Msg) (Manager, tea.Cmd) {
notifType = TypeError
}
}
item := notificationItem{
ID: id,
Text: msg.Text,
Type: notifType,
}

item.TimerCmd = tea.Tick(defaultDuration, func(t time.Time) tea.Msg {
return HideMsg{ID: id}
})

item := notificationItem{ID: id, Text: msg.Text, Type: notifType}
n.items = append([]notificationItem{item}, n.items...)

return *n, item.TimerCmd
var cmd tea.Cmd
if !notifType.persistent() {
cmd = tea.Tick(defaultDuration, func(t time.Time) tea.Msg {
return HideMsg{ID: id}
})
}
return *n, cmd

case HideMsg:
if msg.ID == 0 {
Expand All @@ -143,49 +163,24 @@ func (n *Manager) Update(msg tea.Msg) (Manager, tea.Cmd) {
return *n, nil
}

// maxWidth returns the effective maximum width for notification text.
func (n *Manager) maxWidth() int {
if n.width > 0 {
return max(1, min(maxNotificationWidth, n.width-notificationPadding*2))
}
return maxNotificationWidth
}

func (n *Manager) View() string {
if len(n.items) == 0 {
return ""
}

var views []string
mw := n.maxWidth()
views := make([]string, 0, len(n.items))
for i := len(n.items) - 1; i >= 0; i-- {
item := n.items[i]

// Select style based on notification type
var style lipgloss.Style
switch item.Type {
case TypeError:
style = styles.NotificationErrorStyle
case TypeWarning:
style = styles.NotificationWarningStyle
case TypeInfo:
style = styles.NotificationInfoStyle
default:
style = styles.NotificationStyle
}

// Apply max width constraint and word wrapping
text := item.Text
maxWidth := maxNotificationWidth
if n.width > 0 {
// Use smaller of maxNotificationWidth or available width minus padding
maxWidth = min(maxNotificationWidth, n.width-notificationPadding*2)
}

// Only constrain width if text actually exceeds maxWidth
textWidth := lipgloss.Width(text)
var view string
if textWidth > maxWidth {
// Wrap text using lipgloss Width style - lipgloss will automatically wrap
view = style.Width(maxWidth).Render(text)
} else {
// Use natural width for short text
view = style.Render(text)
}
views = append(views, view)
views = append(views, n.items[i].render(mw))
}

return lipgloss.JoinVertical(lipgloss.Right, views...)
}

Expand Down Expand Up @@ -215,3 +210,34 @@ func (n *Manager) position() (row, col int) {
func (n *Manager) Open() bool {
return len(n.items) > 0
}

// HandleClick checks if the given screen coordinates hit a persistent
// notification and dismisses it. Returns a command if a notification
// was dismissed, nil otherwise.
func (n *Manager) HandleClick(x, y int) tea.Cmd {
if len(n.items) == 0 {
return nil
}

row, col := n.position()
mw := n.maxWidth()
notifY := row

// Walk items bottom-to-top (same render order as View)
for i := len(n.items) - 1; i >= 0; i-- {
item := n.items[i]
view := item.render(mw)
viewHeight := lipgloss.Height(view)

if item.Type.persistent() {
viewWidth := lipgloss.Width(view)
if y >= notifY && y < notifY+viewHeight && x >= col && x < col+viewWidth {
return core.CmdHandler(HideMsg{ID: item.ID})
}
}

notifY += viewHeight
}

return nil
}
5 changes: 5 additions & 0 deletions pkg/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -1727,6 +1727,11 @@ func (m *appModel) switchFocus() (tea.Model, tea.Cmd) {

// handleMouseClick routes mouse clicks to the appropriate component based on Y coordinate.
func (m *appModel) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
// Check if click hits a notification close button
if cmd := m.notification.HandleClick(msg.X, msg.Y); cmd != nil {
return m, cmd
}

// Dialogs use full-window coordinates (they're positioned over the entire screen)
if m.dialogMgr.Open() {
u, cmd := m.dialogMgr.Update(msg)
Expand Down
Loading