Skip to content

Commit 419b73d

Browse files
committed
Make TUI warnings persist until manually dismissed
Warning and error notifications no longer auto-dismiss after 3 seconds. They display an [x] close button and remain visible until the user clicks on them. Success and info notifications continue to auto-dismiss. - Add persistent() and style() methods on notification Type - Add render() method on notificationItem to centralize rendering - Add HandleClick() on Manager for mouse-based dismissal - Clamp maxWidth to prevent underflow on very narrow terminals Fixes #2247 Assisted-By: docker-agent
1 parent abef96e commit 419b73d

File tree

2 files changed

+97
-66
lines changed

2 files changed

+97
-66
lines changed

pkg/tui/components/notification/notification.go

Lines changed: 92 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
)
1414

1515
const (
16+
closeButton = " [x]"
1617
defaultDuration = 3 * time.Second
1718
notificationPadding = 2
1819
maxNotificationWidth = 80 // Maximum width to prevent covering too much screen
@@ -30,6 +31,25 @@ const (
3031
TypeError
3132
)
3233

34+
// persistent returns true for notification types that stay until manually dismissed.
35+
func (t Type) persistent() bool {
36+
return t == TypeWarning || t == TypeError
37+
}
38+
39+
// style returns the lipgloss style for this notification type.
40+
func (t Type) style() lipgloss.Style {
41+
switch t {
42+
case TypeError:
43+
return styles.NotificationErrorStyle
44+
case TypeWarning:
45+
return styles.NotificationWarningStyle
46+
case TypeInfo:
47+
return styles.NotificationInfoStyle
48+
default:
49+
return styles.NotificationStyle
50+
}
51+
}
52+
3353
type ShowMsg struct {
3454
Text string
3555
Type Type // Defaults to TypeSuccess for backward compatibility
@@ -40,39 +60,41 @@ type HideMsg struct {
4060
}
4161

4262
func SuccessCmd(text string) tea.Cmd {
43-
return core.CmdHandler(ShowMsg{
44-
Text: text,
45-
Type: TypeSuccess,
46-
})
63+
return core.CmdHandler(ShowMsg{Text: text, Type: TypeSuccess})
4764
}
4865

4966
func WarningCmd(text string) tea.Cmd {
50-
return core.CmdHandler(ShowMsg{
51-
Text: text,
52-
Type: TypeWarning,
53-
})
67+
return core.CmdHandler(ShowMsg{Text: text, Type: TypeWarning})
5468
}
5569

5670
func InfoCmd(text string) tea.Cmd {
57-
return core.CmdHandler(ShowMsg{
58-
Text: text,
59-
Type: TypeInfo,
60-
})
71+
return core.CmdHandler(ShowMsg{Text: text, Type: TypeInfo})
6172
}
6273

6374
func ErrorCmd(text string) tea.Cmd {
64-
return core.CmdHandler(ShowMsg{
65-
Text: text,
66-
Type: TypeError,
67-
})
75+
return core.CmdHandler(ShowMsg{Text: text, Type: TypeError})
6876
}
6977

7078
// notificationItem represents a single notification
7179
type notificationItem struct {
72-
ID uint64
73-
Text string
74-
Type Type
75-
TimerCmd tea.Cmd
80+
ID uint64
81+
Text string
82+
Type Type
83+
}
84+
85+
// render returns the styled view string for this notification item,
86+
// including a close button for persistent notifications.
87+
func (item notificationItem) render(maxWidth int) string {
88+
text := item.Text
89+
if item.Type.persistent() {
90+
text += closeButton
91+
}
92+
93+
style := item.Type.style()
94+
if lipgloss.Width(text) > maxWidth {
95+
return style.Width(maxWidth).Render(text)
96+
}
97+
return style.Render(text)
7698
}
7799

78100
// Manager represents a notification manager that displays
@@ -110,19 +132,17 @@ func (n *Manager) Update(msg tea.Msg) (Manager, tea.Cmd) {
110132
notifType = TypeError
111133
}
112134
}
113-
item := notificationItem{
114-
ID: id,
115-
Text: msg.Text,
116-
Type: notifType,
117-
}
118-
119-
item.TimerCmd = tea.Tick(defaultDuration, func(t time.Time) tea.Msg {
120-
return HideMsg{ID: id}
121-
})
122135

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

125-
return *n, item.TimerCmd
139+
var cmd tea.Cmd
140+
if !notifType.persistent() {
141+
cmd = tea.Tick(defaultDuration, func(t time.Time) tea.Msg {
142+
return HideMsg{ID: id}
143+
})
144+
}
145+
return *n, cmd
126146

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

166+
// maxWidth returns the effective maximum width for notification text.
167+
func (n *Manager) maxWidth() int {
168+
if n.width > 0 {
169+
return max(1, min(maxNotificationWidth, n.width-notificationPadding*2))
170+
}
171+
return maxNotificationWidth
172+
}
173+
146174
func (n *Manager) View() string {
147175
if len(n.items) == 0 {
148176
return ""
149177
}
150178

151-
var views []string
179+
mw := n.maxWidth()
180+
views := make([]string, 0, len(n.items))
152181
for i := len(n.items) - 1; i >= 0; i-- {
153-
item := n.items[i]
154-
155-
// Select style based on notification type
156-
var style lipgloss.Style
157-
switch item.Type {
158-
case TypeError:
159-
style = styles.NotificationErrorStyle
160-
case TypeWarning:
161-
style = styles.NotificationWarningStyle
162-
case TypeInfo:
163-
style = styles.NotificationInfoStyle
164-
default:
165-
style = styles.NotificationStyle
166-
}
167-
168-
// Apply max width constraint and word wrapping
169-
text := item.Text
170-
maxWidth := maxNotificationWidth
171-
if n.width > 0 {
172-
// Use smaller of maxNotificationWidth or available width minus padding
173-
maxWidth = min(maxNotificationWidth, n.width-notificationPadding*2)
174-
}
175-
176-
// Only constrain width if text actually exceeds maxWidth
177-
textWidth := lipgloss.Width(text)
178-
var view string
179-
if textWidth > maxWidth {
180-
// Wrap text using lipgloss Width style - lipgloss will automatically wrap
181-
view = style.Width(maxWidth).Render(text)
182-
} else {
183-
// Use natural width for short text
184-
view = style.Render(text)
185-
}
186-
views = append(views, view)
182+
views = append(views, n.items[i].render(mw))
187183
}
188-
189184
return lipgloss.JoinVertical(lipgloss.Right, views...)
190185
}
191186

@@ -215,3 +210,34 @@ func (n *Manager) position() (row, col int) {
215210
func (n *Manager) Open() bool {
216211
return len(n.items) > 0
217212
}
213+
214+
// HandleClick checks if the given screen coordinates hit a persistent
215+
// notification and dismisses it. Returns a command if a notification
216+
// was dismissed, nil otherwise.
217+
func (n *Manager) HandleClick(x, y int) tea.Cmd {
218+
if len(n.items) == 0 {
219+
return nil
220+
}
221+
222+
row, col := n.position()
223+
mw := n.maxWidth()
224+
notifY := row
225+
226+
// Walk items bottom-to-top (same render order as View)
227+
for i := len(n.items) - 1; i >= 0; i-- {
228+
item := n.items[i]
229+
view := item.render(mw)
230+
viewHeight := lipgloss.Height(view)
231+
232+
if item.Type.persistent() {
233+
viewWidth := lipgloss.Width(view)
234+
if y >= notifY && y < notifY+viewHeight && x >= col && x < col+viewWidth {
235+
return core.CmdHandler(HideMsg{ID: item.ID})
236+
}
237+
}
238+
239+
notifY += viewHeight
240+
}
241+
242+
return nil
243+
}

pkg/tui/tui.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,6 +1727,11 @@ func (m *appModel) switchFocus() (tea.Model, tea.Cmd) {
17271727

17281728
// handleMouseClick routes mouse clicks to the appropriate component based on Y coordinate.
17291729
func (m *appModel) handleMouseClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
1730+
// Check if click hits a notification close button
1731+
if cmd := m.notification.HandleClick(msg.X, msg.Y); cmd != nil {
1732+
return m, cmd
1733+
}
1734+
17301735
// Dialogs use full-window coordinates (they're positioned over the entire screen)
17311736
if m.dialogMgr.Open() {
17321737
u, cmd := m.dialogMgr.Update(msg)

0 commit comments

Comments
 (0)