1package client
2
3import (
4 "fmt"
5 "io"
6 "strings"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/picker"
11 "github.com/bnema/vev/internal/usecase/ui"
12)
13
14// drawClientToast draws a client-local toast without changing terminal state.
15// The attach main loop owns both this write and the later daemon-frame
16// reconciliation; input pumps must only publish a request for it.
17// The anchor places it: transitions stay centered, notices sit top-right so
18// they never cover the picker list. The border is colored by severity.
19func drawClientToast(out io.Writer, size domain.Size, message string, anchor domain.Anchor, severity domain.NoticeSeverity) (domain.Rect, error) {
20 bounds := ui.ToastBounds(size, ui.Toast{Message: message, Anchor: anchor})
21 if bounds.Width <= 0 || bounds.Height <= 0 {
22 return domain.Rect{}, nil
23 }
24 return bounds, writeClientToast(out, bounds, clientToastLines(bounds, message, toastBorderSGR(severity)))
25}
26
27// toastBorderSGR is the border color per severity, in the same fixed
28// xterm-256 colors as the picker's problem dots. Info keeps the terminal's
29// default color.
30func toastBorderSGR(severity domain.NoticeSeverity) string {
31 switch severity {
32 case domain.NoticeError:
33 return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemError)
34 case domain.NoticeWarn:
35 return fmt.Sprintf("\x1b[38;5;%dm", picker.ColorProblemWarn)
36 default:
37 return ""
38 }
39}
40
41// clientToastLines renders the toast box as one string per row, exactly
42// bounds.Width columns wide.
43func clientToastLines(bounds domain.Rect, message, borderSGR string) []string {
44 if bounds.Width <= 0 || bounds.Height <= 0 {
45 return nil
46 }
47 frame := renderer.NewFrame(bounds.Width, bounds.Height)
48 ui.DrawToast(frame, ui.Toast{Message: message, Anchor: domain.AnchorCenter}, ui.ToastStyles{
49 Text: renderer.DefaultStyle(),
50 Box: renderer.DefaultStyle(),
51 })
52 lines := make([]string, bounds.Height)
53 for y := range bounds.Height {
54 var b strings.Builder
55 b.Grow(bounds.Width)
56 colored := false
57 for x := range bounds.Width {
58 border := y == 0 || y == bounds.Height-1 || x == 0 || x == bounds.Width-1
59 if borderSGR != "" && border != colored {
60 if border {
61 b.WriteString(borderSGR)
62 } else {
63 b.WriteString("\x1b[39m")
64 }
65 colored = border
66 }
67 cell := frame.At(x, y)
68 if cell.Continuation {
69 // The wide rune before it already covers this column.
70 continue
71 }
72 if cell.Rune == 0 {
73 b.WriteRune(' ')
74 continue
75 }
76 b.WriteRune(cell.Rune)
77 }
78 if colored {
79 b.WriteString("\x1b[39m")
80 }
81 lines[y] = b.String()
82 }
83 return lines
84}
85
86// blankToastLines erases a toast box. The session under it belongs to the
87// daemon, so blank cells are the best the client can restore.
88func blankToastLines(bounds domain.Rect) []string {
89 lines := make([]string, max(0, bounds.Height))
90 for i := range lines {
91 lines[i] = strings.Repeat(" ", max(0, bounds.Width))
92 }
93 return lines
94}
95
96func writeClientToast(out io.Writer, bounds domain.Rect, lines []string) error {
97 if _, err := io.WriteString(out, "\x1b[s"); err != nil {
98 return err
99 }
100 for i, line := range lines {
101 if _, err := fmt.Fprintf(out, "\x1b[%d;%dH\x1b[0m%s", bounds.Y+i+1, bounds.X+1, line); err != nil {
102 return err
103 }
104 }
105 _, err := io.WriteString(out, "\x1b[0m\x1b[u")
106 return err
107}