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.
289 lines
7.1 KiB
Go
289 lines
7.1 KiB
Go
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
|
|
}
|
|
}
|