session.go

  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}