1package ui
2
3import (
4 "strings"
5
6 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/key"
8 tea "charm.land/bubbletea/v2"
9 "charm.land/lipgloss/v2"
10
11 "git.secluded.site/keld/internal/theme"
12)
13
14// chromeLines is the number of terminal lines reserved for the
15// session's visual frame. Screens receive the terminal height minus
16// this value so they can size themselves without overflowing.
17//
18// breadcrumb: 1 (0 on first screen, but we reserve 1 for consistency)
19// title + gap: 2
20// gap + help: 2
21const chromeLines = 5
22
23// keys defines the global key bindings that the session handles
24// regardless of which screen is active.
25var keys = struct {
26 Back key.Binding
27 Quit key.Binding
28}{
29 Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
30 Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")),
31}
32
33// Session is the top-level tea.Model for keld's interactive mode. It
34// manages a stack of screens, renders chrome, and handles global
35// navigation (back/cancel).
36type Session struct {
37 // screens holds every screen in the flow. The session keeps them
38 // all so back navigation can re-activate earlier screens with
39 // their state intact.
40 screens []Screen
41
42 // cursor points to the currently active screen in the screens
43 // slice.
44 cursor int
45
46 // styles holds the pre-computed theme.
47 styles *theme.Styles
48
49 // help renders the key binding bar at the bottom.
50 help help.Model
51
52 // width and height store the last known terminal dimensions.
53 width, height int
54
55 // lastSize caches the most recent WindowSizeMsg so it can be
56 // replayed to screens activated via advance or back navigation.
57 lastSize *tea.WindowSizeMsg
58
59 // done is set when the session should exit (cancel or complete).
60 done bool
61
62 // completed distinguishes successful flow completion from
63 // cancellation. The caller checks this via Completed() to decide
64 // whether to execute restic.
65 completed bool
66}
67
68// New creates a session with the given screens and shared styles.
69// The caller allocates a [theme.Styles] and passes the same pointer
70// to both screen constructors and this function, so everyone shares
71// a single theme that the session updates on background detection.
72//
73// Screens that should be skipped (because their value is already
74// resolved) should simply not be included in the slice — the caller
75// is responsible for building only the screens that need user input.
76func New(screens []Screen, styles *theme.Styles) Session {
77 h := help.New()
78 h.Styles = styles.Help
79
80 // Copy the slice so mutations during forwardToScreen don't
81 // affect the caller's original.
82 owned := make([]Screen, len(screens))
83 copy(owned, screens)
84
85 return Session{
86 screens: owned,
87 styles: styles,
88 help: h,
89 }
90}
91
92// Completed reports whether the user finished the entire flow
93// (as opposed to cancelling with Ctrl+C or Esc on the first screen).
94func (s Session) Completed() bool {
95 return s.completed
96}
97
98// Init requests the terminal background colour and window size, then
99// initialises the first screen.
100func (s Session) Init() tea.Cmd {
101 cmds := []tea.Cmd{
102 tea.RequestBackgroundColor,
103 tea.RequestWindowSize,
104 }
105 if len(s.screens) > 0 {
106 cmds = append(cmds, s.screens[s.cursor].Init())
107 }
108 return tea.Batch(cmds...)
109}
110
111// Update handles messages. Global concerns (background detection,
112// resize, Ctrl+C) are handled here. Esc and all other messages are
113// forwarded to the active screen. The session navigates back when
114// it receives a [BackMsg] and advances when it receives a [DoneMsg].
115func (s Session) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116 switch msg := msg.(type) {
117 case tea.BackgroundColorMsg:
118 *s.styles = theme.New(msg.IsDark())
119 s.help.Styles = s.styles.Help
120 // Forward to the active screen so it can rebuild any
121 // cached themed state (e.g. huh form themes).
122 return s.forwardToScreen(msg)
123
124 case tea.WindowSizeMsg:
125 s.width = msg.Width
126 s.height = msg.Height
127 s.lastSize = &msg
128 s.help.SetWidth(msg.Width)
129 // Forward an adjusted size to the active screen so it knows
130 // how much vertical space is available after chrome.
131 return s.forwardToScreen(s.adjustedSizeMsg())
132
133 case tea.KeyPressMsg:
134 if key.Matches(msg, keys.Quit) {
135 s.done = true
136 return s, tea.Quit
137 }
138 // All other keys, including Esc, are forwarded to the screen.
139 // Screens that don't handle Esc return BackCmd.
140 return s.forwardToScreen(msg)
141
142 case BackMsg:
143 return s.navigateBack()
144
145 case DoneMsg:
146 return s.advance()
147
148 case ExtendMsg:
149 return s.extend(msg)
150 }
151
152 return s.forwardToScreen(msg)
153}
154
155// View renders the chrome (breadcrumb, title, help bar) around the
156// active screen's content.
157func (s Session) View() tea.View {
158 if s.done || len(s.screens) == 0 {
159 return tea.NewView("")
160 }
161
162 screen := s.screens[s.cursor]
163 sty := s.styles
164
165 var b strings.Builder
166
167 // Breadcrumb: show selections from completed screens before the
168 // current one.
169 if crumbs := s.breadcrumb(); crumbs != "" {
170 b.WriteString(sty.Breadcrumb.Render(crumbs))
171 b.WriteString("\n")
172 }
173
174 // Title of the current screen.
175 if title := screen.Title(); title != "" {
176 b.WriteString(sty.Title.Render(title))
177 b.WriteString("\n\n")
178 }
179
180 // Screen content.
181 b.WriteString(screen.View())
182
183 // Help bar: screen-specific bindings + global bindings. Copy
184 // the slice to avoid mutating the screen's backing array.
185 screenBindings := screen.KeyBindings()
186 bindings := make([]key.Binding, 0, len(screenBindings)+2)
187 bindings = append(bindings, screenBindings...)
188 bindings = append(bindings, keys.Back, keys.Quit)
189 b.WriteString("\n")
190 b.WriteString(s.help.ShortHelpView(bindings))
191 b.WriteString("\n")
192
193 return tea.NewView(b.String())
194}
195
196// breadcrumb builds the breadcrumb string from selections of all
197// completed screens before the current one.
198func (s Session) breadcrumb() string {
199 var parts []string
200 for i := 0; i < s.cursor; i++ {
201 if sel := s.screens[i].Selection(); sel != "" {
202 parts = append(parts, sel)
203 }
204 }
205 if len(parts) == 0 {
206 return ""
207 }
208 return strings.Join(parts, " "+lipgloss.NewStyle().Foreground(s.styles.Accent).Render("▸")+" ")
209}
210
211// navigateBack moves to the previous screen, or exits if already on
212// the first screen.
213func (s Session) navigateBack() (tea.Model, tea.Cmd) {
214 if s.cursor <= 0 {
215 // On the first screen: back exits.
216 s.done = true
217 return s, tea.Quit
218 }
219 s.cursor--
220 return s, s.activateScreen()
221}
222
223// advance moves to the next screen. If the current screen is the last
224// one, the flow is complete — the session marks itself done and quits.
225func (s Session) advance() (tea.Model, tea.Cmd) {
226 if s.cursor >= len(s.screens)-1 {
227 // Last screen completed: flow is done.
228 s.done = true
229 s.completed = true
230 return s, tea.Quit
231 }
232 s.cursor++
233 return s, s.activateScreen()
234}
235
236// extend appends screens after the current cursor position, replacing
237// any screens that were already queued beyond it. This lets the flow
238// be built dynamically — e.g. after resolving which command-specific
239// screens are needed.
240func (s Session) extend(msg ExtendMsg) (tea.Model, tea.Cmd) {
241 if len(msg.Screens) == 0 {
242 return s, nil
243 }
244 // Truncate to current position + 1 (keep the active screen and
245 // all completed screens before it), then append the new screens.
246 s.screens = append(s.screens[:s.cursor+1], msg.Screens...)
247 return s, nil
248}
249
250// activateScreen initialises the screen at the current cursor and
251// sends it the adjusted window size so it has correct dimensions
252// immediately. Used by both navigateBack and advance.
253func (s Session) activateScreen() tea.Cmd {
254 cmd := s.screens[s.cursor].Init()
255 if s.lastSize != nil {
256 updated, sizeCmd := s.screens[s.cursor].Update(s.adjustedSizeMsg())
257 s.screens[s.cursor] = updated
258 cmd = tea.Batch(cmd, sizeCmd)
259 }
260 return cmd
261}
262
263// forwardToScreen sends a message to the active screen and updates it.
264func (s Session) forwardToScreen(msg tea.Msg) (tea.Model, tea.Cmd) {
265 if len(s.screens) == 0 {
266 return s, nil
267 }
268
269 updated, cmd := s.screens[s.cursor].Update(msg)
270 s.screens[s.cursor] = updated
271 return s, cmd
272}
273
274// adjustedSizeMsg returns a WindowSizeMsg with the height reduced by
275// the chrome lines so screens can size themselves correctly.
276func (s Session) adjustedSizeMsg() tea.WindowSizeMsg {
277 return tea.WindowSizeMsg{Width: s.width, Height: max(1, s.height-chromeLines)}
278}