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 } }