1package client
2
3import (
4 "fmt"
5 "io"
6 "strings"
7
8 renderer "github.com/bnema/vev-vt"
9 ansirenderer "github.com/bnema/vev-vt/ansi"
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/bnema/vev/internal/usecase/ui"
13)
14
15// drawClientToast draws a client-local toast without changing terminal state.
16// The attach main loop owns both this write and the later daemon-frame
17// reconciliation; input pumps must only publish a request for it.
18// The anchor places it: transitions stay centered, notices sit top-right so
19// they never cover the picker list. The border is colored by severity.
20//
21// The border uses fixed xterm-256 colors and does not follow the attachment
22// color profile, so production callers (the transition notice) pass only
23// domain.NoticeInfo, which emits no color. Toasts with a severity that must
24// honor 16-color or monochrome terminals go through toastBorderSGRFor, as the
25// picker does.
26func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {
27 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
28 if bounds.Width <= 0 || bounds.Height <= 0 {
29 return domain.Rect{}, nil
30 }
31 return bounds, writeClientToast(out, bounds, clientToastLines(bounds, message, toastBorderSGR(severity)))
32}
33
34// toastBorderSGR is the border color per severity, in the same fixed
35// xterm-256 colors as the picker's problem dots. Info keeps the terminal's
36// default color.
37func toastBorderSGR(severity domain.NoticeSeverity) string {
38 switch severity {
39 case domain.NoticeError:
40 return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemError)
41 case domain.NoticeWarn:
42 return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemWarn)
43 default:
44 return ""
45 }
46}
47
48// toastBorderSGRFor is toastBorderSGR for one renderer color profile. The
49// toast bypasses the renderer, so it must follow the profile itself: 16-color
50// terminals get basic red/yellow, monochrome terminals get bold for problems.
51// TrueColor and 256-color output is unchanged.
52func toastBorderSGRFor(severity domain.NoticeSeverity, profile ansirenderer.ColorProfile) string {
53 switch profile {
54 case ansirenderer.ColorProfileANSI16:
55 switch severity {
56 case domain.NoticeError:
57 return "\x1b[31m"
58 case domain.NoticeWarn:
59 return "\x1b[33m"
60 }
61 return ""
62 case ansirenderer.ColorProfileMonochrome:
63 if severity == domain.NoticeError || severity == domain.NoticeWarn {
64 return "\x1b[1m"
65 }
66 return ""
67 default:
68 return toastBorderSGR(severity)
69 }
70}
71
72// toastBorderReset undoes toastBorderSGR's attribute: default foreground for
73// colors, normal intensity for the monochrome bold border.
74func toastBorderReset(borderSGR string) string {
75 if borderSGR == "\x1b[1m" {
76 return "\x1b[22m"
77 }
78 return "\x1b[39m"
79}
80
81// clientToastLines renders the toast box as one string per row, exactly
82// bounds.Width columns wide.
83func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
84 if bounds.Width <= 0 || bounds.Height <= 0 {
85 return nil
86 }
87 frame := renderer.NewFrame(bounds.Width, bounds.Height)
88 ui.DrawToast(frame, ui.Toast{Message: message, Anchor: domain.AnchorCenter}, ui.ToastStyles{
89 Text: renderer.DefaultStyle(),
90 Box: renderer.DefaultStyle(),
91 })
92 lines := make([]string, bounds.Height)
93 for y := range bounds.Height {
94 var b strings.Builder
95 b.Grow(bounds.Width)
96 colored := false
97 for x := range bounds.Width {
98 border := y == 0 || y == bounds.Height-1 || x == 0 || x == bounds.Width-1
99 if borderSGR != "" && border != colored {
100 if border {
101 b.WriteString(borderSGR)
102 } else {
103 b.WriteString(toastBorderReset(borderSGR))
104 }
105 colored = border
106 }
107 cell := frame.At(x, y)
108 if cell.Continuation {
109 // The wide rune before it already covers this column.
110 continue
111 }
112 if cell.Rune == 0 {
113 b.WriteRune(' ')
114 continue
115 }
116 b.WriteRune(cell.Rune)
117 }
118 if colored {
119 b.WriteString(toastBorderReset(borderSGR))
120 }
121 lines[y] = b.String()
122 }
123 return lines
124}
125
126// blankToastLines erases a toast box. The session under it belongs to the
127// daemon, so blank cells are the best the client can restore.
128func blankToastLines(bounds domain.Rect) []string {
129 lines := make([]string, max(0, bounds.Height))
130 for i := range lines {
131 lines[i] = strings.Repeat(" ", max(0, bounds.Width))
132 }
133 return lines
134}
135
136func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error {
137 if _, err := io.WriteString(out, "\x1b[s"); err != nil {
138 return err
139 }
140 for i, line := range lines {
141 if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
142 return err
143 }
144 }
145 _, err := io.WriteString(out, "\x1b[0m\x1b[u")
146 return err
147}