Skip to content

Commit 2457e8e

Browse files
committed
Add a notification component
Shows a message in the bottom right corner, lasts for 3 seconds Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
1 parent 1fb10cd commit 2457e8e

3 files changed

Lines changed: 205 additions & 10 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package notification
2+
3+
import (
4+
"time"
5+
6+
tea "github.com/charmbracelet/bubbletea/v2"
7+
"github.com/charmbracelet/lipgloss/v2"
8+
9+
"github.com/docker/cagent/pkg/tui/styles"
10+
)
11+
12+
const (
13+
defaultDuration = 3 * time.Second
14+
notificationPadding = 2
15+
maxWidth = 50
16+
)
17+
18+
type ShowMsg struct {
19+
Text string
20+
}
21+
22+
type HideMsg struct{}
23+
24+
type State int
25+
26+
const (
27+
StateHidden State = iota
28+
StateVisible
29+
)
30+
31+
// Notification represents a notification component that displays
32+
// a message in the bottom right corner of the screen
33+
type Notification struct {
34+
width, height int
35+
text string
36+
state State
37+
}
38+
39+
func New() Notification {
40+
return Notification{
41+
state: StateHidden,
42+
}
43+
}
44+
45+
func (n *Notification) SetSize(width, height int) {
46+
n.width = width
47+
n.height = height
48+
}
49+
50+
func (n *Notification) Update(msg tea.Msg) (Notification, tea.Cmd) {
51+
switch msg := msg.(type) {
52+
case tea.WindowSizeMsg:
53+
n.width = msg.Width
54+
n.height = msg.Height
55+
return *n, nil
56+
57+
case ShowMsg:
58+
n.text = msg.Text
59+
n.state = StateVisible
60+
return *n, tea.Tick(defaultDuration, func(t time.Time) tea.Msg {
61+
return HideMsg{}
62+
})
63+
64+
case HideMsg:
65+
n.state = StateHidden
66+
n.text = ""
67+
return *n, nil
68+
}
69+
70+
return *n, nil
71+
}
72+
73+
func (n *Notification) View() string {
74+
if n.state == StateHidden || n.text == "" {
75+
return ""
76+
}
77+
78+
notificationStyle := styles.BaseStyle.
79+
Border(lipgloss.RoundedBorder()).
80+
BorderForeground(styles.SuccessStyle.GetForeground()).
81+
Padding(0, 1).
82+
MaxWidth(maxWidth)
83+
84+
return notificationStyle.Render(n.text)
85+
}
86+
87+
func (n *Notification) GetLayer() *lipgloss.Layer {
88+
if n.state == StateHidden || n.text == "" {
89+
return nil
90+
}
91+
92+
view := n.View()
93+
row, col := n.position()
94+
95+
return lipgloss.NewLayer(view).X(col).Y(row)
96+
}
97+
98+
func (n *Notification) position() (row, col int) {
99+
notificationView := n.View()
100+
viewHeight := lipgloss.Height(notificationView)
101+
viewWidth := lipgloss.Width(notificationView)
102+
103+
// Position in bottom right corner with padding
104+
row = max(0, n.height-viewHeight-notificationPadding)
105+
col = max(0, n.width-viewWidth-notificationPadding)
106+
107+
return row, col
108+
}
109+
110+
func (n *Notification) IsVisible() bool {
111+
return n.state != StateHidden
112+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package notification
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestNotification_InitialState(t *testing.T) {
10+
n := New()
11+
12+
require.Equal(t, StateHidden, n.state)
13+
require.Empty(t, n.text)
14+
require.False(t, n.IsVisible())
15+
}
16+
17+
func TestNotification_Show(t *testing.T) {
18+
n := New()
19+
20+
updated, _ := n.Update(ShowMsg{Text: "Test notification"})
21+
22+
require.Equal(t, StateVisible, updated.state)
23+
require.Equal(t, "Test notification", updated.text)
24+
require.True(t, updated.IsVisible())
25+
require.NotEmpty(t, updated.View())
26+
}
27+
28+
func TestNotification_Hide(t *testing.T) {
29+
n := New()
30+
31+
updated, _ := n.Update(ShowMsg{Text: "Test"})
32+
updated, _ = updated.Update(HideMsg{})
33+
34+
require.Equal(t, StateHidden, updated.state)
35+
require.Empty(t, updated.text)
36+
require.False(t, updated.IsVisible())
37+
require.Empty(t, updated.View())
38+
}
39+
40+
func TestNotification_Position(t *testing.T) {
41+
n := New()
42+
n.SetSize(100, 50)
43+
updated, _ := n.Update(ShowMsg{Text: "Test"})
44+
row, col := updated.position()
45+
46+
require.Equal(t, 45, row)
47+
require.Equal(t, 90, col)
48+
}
49+
50+
func TestNotification_GetLayer(t *testing.T) {
51+
n := New()
52+
53+
require.Nil(t, n.GetLayer())
54+
55+
updated, _ := n.Update(ShowMsg{Text: "Test"})
56+
require.NotNil(t, updated.GetLayer())
57+
}

pkg/tui/tui.go

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/docker/cagent/pkg/evaluation"
1313
"github.com/docker/cagent/pkg/runtime"
1414
"github.com/docker/cagent/pkg/tui/components/messages"
15+
"github.com/docker/cagent/pkg/tui/components/notification"
1516
"github.com/docker/cagent/pkg/tui/components/statusbar"
1617
"github.com/docker/cagent/pkg/tui/core"
1718
"github.com/docker/cagent/pkg/tui/dialog"
@@ -41,8 +42,9 @@ type appModel struct {
4142
width, height int
4243
keyMap KeyMap
4344

44-
chatPage chatpage.Page
45-
statusBar statusbar.StatusBar
45+
chatPage chatpage.Page
46+
statusBar statusbar.StatusBar
47+
notification notification.Notification
4648

4749
// Dialog system
4850
dialog dialog.Manager
@@ -75,10 +77,11 @@ func DefaultKeyMap() KeyMap {
7577
// New creates and initializes a new TUI application model
7678
func New(a *app.App) tea.Model {
7779
t := &appModel{
78-
chatPage: chatpage.New(a),
79-
keyMap: DefaultKeyMap(),
80-
dialog: dialog.New(),
81-
application: a,
80+
chatPage: chatpage.New(a),
81+
keyMap: DefaultKeyMap(),
82+
dialog: dialog.New(),
83+
notification: notification.New(),
84+
application: a,
8285
}
8386

8487
t.statusBar = statusbar.New(t)
@@ -120,6 +123,11 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120123
cmd := a.handleWindowResize(msg.Width, msg.Height)
121124
return a, cmd
122125

126+
case notification.ShowMsg, notification.HideMsg:
127+
updated, cmd := a.notification.Update(msg)
128+
a.notification = updated
129+
return a, cmd
130+
123131
case tea.KeyPressMsg:
124132
cmd := a.handleKeyPressMsg(msg)
125133
return a, cmd
@@ -197,6 +205,9 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd {
197205
// Update status bar width
198206
a.statusBar.SetWidth(a.width)
199207

208+
// Update notification size
209+
a.notification.SetSize(a.width, a.height)
210+
200211
return tea.Batch(cmds...)
201212
}
202213

@@ -271,12 +282,27 @@ func (a *appModel) View() tea.View {
271282

272283
baseView := lipgloss.JoinVertical(lipgloss.Top, components...)
273284

274-
if a.dialog.HasDialog() {
285+
// Check if we need to render any overlays (dialogs or notifications)
286+
hasOverlays := a.dialog.HasDialog() || a.notification.IsVisible()
287+
288+
if hasOverlays {
275289
baseLayer := lipgloss.NewLayer(baseView)
276-
dialogLayers := a.dialog.GetLayers()
290+
var allLayers []*lipgloss.Layer
291+
allLayers = append(allLayers, baseLayer)
277292

278-
allLayers := []*lipgloss.Layer{baseLayer}
279-
allLayers = append(allLayers, dialogLayers...)
293+
// Add dialog layers
294+
if a.dialog.HasDialog() {
295+
dialogLayers := a.dialog.GetLayers()
296+
allLayers = append(allLayers, dialogLayers...)
297+
}
298+
299+
// Add notification layer (should be on top)
300+
if a.notification.IsVisible() {
301+
notificationLayer := a.notification.GetLayer()
302+
if notificationLayer != nil {
303+
allLayers = append(allLayers, notificationLayer)
304+
}
305+
}
280306

281307
canvas := lipgloss.NewCanvas(allLayers...)
282308
return toFullscreenView(canvas.Render())

0 commit comments

Comments
 (0)