Add stackable toast notifications
Replaces the single-slot status-line flash with a top-right toast stack over the focused pane. flashError, flashTransient, and notifyAttention all push onto the same stack (cap 5, FIFO drop). Ctrl-N dismisses the most recent toast; empty stack falls through to the focused PTY so readline / nano / emacs / opencode bindings keep working. A new "Clear notifications" palette item empties the stack.
This commit is contained in:
288
internal/app/toast.go
Normal file
288
internal/app/toast.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// toastKind classifies a toast for styling and for migrating the
|
||||
// pre-existing flashError / flashTransient / notifyAttention call
|
||||
// sites onto the new stack.
|
||||
type toastKind int
|
||||
|
||||
const (
|
||||
toastInfo toastKind = iota
|
||||
toastError
|
||||
toastAttention
|
||||
)
|
||||
|
||||
// toast is one entry in the host-level notification stack. Toasts
|
||||
// persist until the user dismisses them with Ctrl-N or the
|
||||
// "Clear notifications" palette command — there's no auto-expiry.
|
||||
type toast struct {
|
||||
id uint64
|
||||
kind toastKind
|
||||
text string
|
||||
}
|
||||
|
||||
// toastStackCap caps how many toasts can be visible at once.
|
||||
// Older entries drop off the bottom when a new push would exceed it.
|
||||
const toastStackCap = 5
|
||||
|
||||
// toastBoxMaxWidth bounds the rendered box width so a wide pane
|
||||
// doesn't produce huge toasts. Boxes shrink below this when the pane
|
||||
// is narrow.
|
||||
const toastBoxMaxWidth = 50
|
||||
|
||||
// toastBoxMinWidth is the floor below which we refuse to render —
|
||||
// any narrower and there's not enough room for borders + content.
|
||||
const toastBoxMinWidth = 20
|
||||
|
||||
// toastStack owns the ordered list of live toasts. Oldest at
|
||||
// index 0, newest (visually topmost) at the end. The stack's own
|
||||
// mutex is intentionally separate from uiState.mu so push / dismiss
|
||||
// can be called from any goroutine without participating in the
|
||||
// host's bigger lock-ordering rules.
|
||||
type toastStack struct {
|
||||
mu sync.Mutex
|
||||
items []toast
|
||||
next uint64
|
||||
}
|
||||
|
||||
func (s *toastStack) push(kind toastKind, text string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.next++
|
||||
s.items = append(s.items, toast{id: s.next, kind: kind, text: text})
|
||||
if len(s.items) > toastStackCap {
|
||||
s.items = s.items[len(s.items)-toastStackCap:]
|
||||
}
|
||||
}
|
||||
|
||||
// dismissTop pops the most recent toast (the one rendered at the
|
||||
// top of the stack). Returns true if something was removed so
|
||||
// callers can decide whether to repaint.
|
||||
func (s *toastStack) dismissTop() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) == 0 {
|
||||
return false
|
||||
}
|
||||
s.items = s.items[:len(s.items)-1]
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *toastStack) clear() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) == 0 {
|
||||
return false
|
||||
}
|
||||
s.items = s.items[:0]
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *toastStack) snapshot() []toast {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]toast, len(s.items))
|
||||
copy(out, s.items)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *toastStack) length() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.items)
|
||||
}
|
||||
|
||||
// notifyToast is the single entry point that the former flash
|
||||
// helpers now delegate to. It pushes onto the stack and triggers a
|
||||
// repaint of the focused surface so the new toast appears
|
||||
// immediately; the repaint path also re-renders the stack on top.
|
||||
func (st *uiState) notifyToast(kind toastKind, text string) {
|
||||
st.toasts.push(kind, text)
|
||||
st.refreshToastSurface()
|
||||
}
|
||||
|
||||
// refreshToastSurface re-renders whatever surface the toasts are
|
||||
// drawn over (focused child, focused pad, or the empty-state
|
||||
// canvas). Each of those paths calls renderToasts at the end, so
|
||||
// the toast layer is always reapplied on top of a freshly-drawn
|
||||
// pane. Centralised so push / dismiss / clear share one code path.
|
||||
func (st *uiState) refreshToastSurface() {
|
||||
st.mu.Lock()
|
||||
focusedPad := st.focusedPad
|
||||
focusedID := st.focusedID
|
||||
palOpen := st.palette != nil
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
// Palette owns the whole screen while it's open; toasts will
|
||||
// repaint via closePalette's restore path.
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case focusedPad != "":
|
||||
st.repaintFocusedPad()
|
||||
case focusedID != "":
|
||||
st.repaintFocused()
|
||||
default:
|
||||
st.renderEmptyState()
|
||||
}
|
||||
}
|
||||
|
||||
// renderToasts draws the toast stack over the top-right of the
|
||||
// focused pane. Called from repaintFocused / repaintFocusedPad /
|
||||
// renderEmptyState after they finish so toasts always sit on top of
|
||||
// freshly-redrawn pane content. Safe to call when the stack is
|
||||
// empty (no-op).
|
||||
func (st *uiState) renderToasts() {
|
||||
items := st.toasts.snapshot()
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
st.mu.Lock()
|
||||
palOpen := st.palette != nil
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
return
|
||||
}
|
||||
layout := st.layoutSnapshot()
|
||||
paneCols := int(layout.childCols())
|
||||
paneRows := int(layout.childRows())
|
||||
if paneCols < toastBoxMinWidth+2 || paneRows < 3 {
|
||||
return
|
||||
}
|
||||
boxWidth := toastBoxMaxWidth
|
||||
if max := paneCols - 4; max < boxWidth {
|
||||
boxWidth = max
|
||||
}
|
||||
if boxWidth < toastBoxMinWidth {
|
||||
return
|
||||
}
|
||||
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b7\x1b[?25l")
|
||||
|
||||
row := int(layout.mainTop) + 1
|
||||
col := int(layout.mainLeft) + paneCols - boxWidth - 1
|
||||
if col < int(layout.mainLeft) {
|
||||
col = int(layout.mainLeft)
|
||||
}
|
||||
|
||||
// Render newest first (visually on top), iterating items in
|
||||
// reverse so the most recent push lands at the smallest row.
|
||||
for idx := len(items) - 1; idx >= 0; idx-- {
|
||||
t := items[idx]
|
||||
isTopmost := idx == len(items)-1
|
||||
hintLine := ""
|
||||
if isTopmost && len(items) > 1 {
|
||||
hintLine = fmt.Sprintf("Ctrl-N · %d more", len(items)-1)
|
||||
}
|
||||
height := 3
|
||||
if hintLine != "" {
|
||||
height++
|
||||
}
|
||||
// Stop if we'd run off the bottom of the pane.
|
||||
if row+height > int(layout.mainTop)+paneRows {
|
||||
break
|
||||
}
|
||||
border := toastBorderStyle(t.kind)
|
||||
|
||||
// Top border.
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("╭")
|
||||
b.WriteString(strings.Repeat("─", boxWidth-2))
|
||||
b.WriteString("╮")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
|
||||
// Content row.
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("│")
|
||||
b.WriteString(styleReset)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(toastIcon(t.kind))
|
||||
body := t.text
|
||||
bodyRoom := contentWidth - 2 // icon + space
|
||||
if visibleLen(body) > bodyRoom {
|
||||
body = clipRunes(body, bodyRoom-1) + "…"
|
||||
}
|
||||
b.WriteString(body)
|
||||
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(body))))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(border)
|
||||
b.WriteString("│")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
|
||||
// Hint row (topmost only, when stack has more than one).
|
||||
if hintLine != "" {
|
||||
if visibleLen(hintLine) > contentWidth {
|
||||
hintLine = clipRunes(hintLine, contentWidth-1) + "…"
|
||||
}
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("│")
|
||||
b.WriteString(styleReset)
|
||||
b.WriteString(" ")
|
||||
b.WriteString(styleHint)
|
||||
b.WriteString(hintLine)
|
||||
b.WriteString(styleReset)
|
||||
b.WriteString(strings.Repeat(" ", max(0, contentWidth-visibleLen(hintLine))))
|
||||
b.WriteString(" ")
|
||||
b.WriteString(border)
|
||||
b.WriteString("│")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
}
|
||||
|
||||
// Bottom border.
|
||||
moveTo(&b, row, col)
|
||||
b.WriteString(border)
|
||||
b.WriteString("╰")
|
||||
b.WriteString(strings.Repeat("─", boxWidth-2))
|
||||
b.WriteString("╯")
|
||||
b.WriteString(styleReset)
|
||||
row++
|
||||
|
||||
// 1-row gap between stacked toasts.
|
||||
row++
|
||||
}
|
||||
|
||||
b.WriteString("\x1b[?25h\x1b8")
|
||||
|
||||
st.outMu.Lock()
|
||||
defer st.outMu.Unlock()
|
||||
_, _ = os.Stdout.WriteString(b.String())
|
||||
}
|
||||
|
||||
func toastBorderStyle(kind toastKind) string {
|
||||
switch kind {
|
||||
case toastError:
|
||||
return styleError
|
||||
case toastAttention:
|
||||
return styleAccent
|
||||
default:
|
||||
return styleBorder
|
||||
}
|
||||
}
|
||||
|
||||
func toastIcon(kind toastKind) string {
|
||||
switch kind {
|
||||
case toastError:
|
||||
return styleError + "✗ " + styleReset
|
||||
case toastAttention:
|
||||
return styleAccent + "! " + styleReset
|
||||
default:
|
||||
return styleHint + "• " + styleReset
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user