From 183afe2c06bad20403b0c60462397f9a4786653b Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 28 Mar 2026 20:17:52 -0600 Subject: [PATCH] Add session model for unified interactive TUI --- internal/ui/screen.go | 81 ++++++ internal/ui/session.go | 261 +++++++++++++++++ internal/ui/session_test.go | 553 ++++++++++++++++++++++++++++++++++++ 3 files changed, 895 insertions(+) create mode 100644 internal/ui/screen.go create mode 100644 internal/ui/session.go create mode 100644 internal/ui/session_test.go diff --git a/internal/ui/screen.go b/internal/ui/screen.go new file mode 100644 index 0000000000000000000000000000000000000000..472a9daa23484c58655dda3e5948815dca57181c --- /dev/null +++ b/internal/ui/screen.go @@ -0,0 +1,81 @@ +// Package ui provides the unified interactive session for keld's TUI. +// +// The session runs a single [tea.Program] that coordinates navigation +// between screens, manages the visual chrome (breadcrumb, title, help +// bar), and handles back navigation and cancellation. +package ui + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// Screen is the interface that every step in an interactive flow must +// satisfy. The session delegates input handling and rendering to the +// active screen without knowing its internals. +// +// Screens are constructed before use and may be re-activated when the +// user navigates back. Implementations should preserve their state +// across deactivation/reactivation cycles so previous answers are +// visible when the user returns. +type Screen interface { + // Init returns the command to run when this screen becomes active. + // Called both on first activation and when the user navigates back + // to this screen. + Init() tea.Cmd + + // Update handles a message and returns the updated screen plus any + // command. The session forwards all messages to the active screen, + // including Esc key presses. + // + // Screens that use Esc internally (e.g. closing a filter, navigating + // to a parent directory) should handle it and return normally. + // Screens that do not need Esc should return a [BackCmd] to signal + // that the session should navigate back. + Update(msg tea.Msg) (Screen, tea.Cmd) + + // View renders the screen's content. The session wraps this with + // chrome (breadcrumb, title, help bar), so the screen should not + // render those elements itself. + View() string + + // Title returns the screen's display title, shown below the + // breadcrumb. + Title() string + + // KeyBindings returns the key bindings to display in the help bar + // for this screen. The session adds the global bindings (Esc, Ctrl+C) + // automatically. + // + // The returned slice must not be modified by the caller. + KeyBindings() []key.Binding + + // Selection returns a short string representing the user's choice + // on this screen, for display in the breadcrumb. Returns "" if the + // screen has not been completed yet. + Selection() string +} + +// BackMsg signals that the active screen wants the session to navigate +// back to the previous screen. Screens return this via [BackCmd] when +// they receive an Esc press they do not handle internally. +type BackMsg struct{} + +// BackCmd is a [tea.Cmd] that produces a [BackMsg]. Screens should +// return this from their Update method when they receive an Esc press +// and have no internal use for it. +func BackCmd() tea.Msg { + return BackMsg{} +} + +// DoneMsg signals that the active screen has been completed and the +// session should advance to the next screen. Screens return this via +// [DoneCmd] when the user confirms their selection. +type DoneMsg struct{} + +// DoneCmd is a [tea.Cmd] that produces a [DoneMsg]. Screens should +// return this from their Update method when the user makes or confirms +// a selection. +func DoneCmd() tea.Msg { + return DoneMsg{} +} diff --git a/internal/ui/session.go b/internal/ui/session.go new file mode 100644 index 0000000000000000000000000000000000000000..c8f78473f07b397f9dacef8e90c00ba307269f74 --- /dev/null +++ b/internal/ui/session.go @@ -0,0 +1,261 @@ +package ui + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.secluded.site/keld/internal/theme" +) + +// chromeLines is the number of terminal lines reserved for the +// session's visual frame. Screens receive the terminal height minus +// this value so they can size themselves without overflowing. +// +// breadcrumb: 1 (0 on first screen, but we reserve 1 for consistency) +// title + gap: 2 +// gap + help: 2 +const chromeLines = 5 + +// keys defines the global key bindings that the session handles +// regardless of which screen is active. +var keys = struct { + Back key.Binding + Quit key.Binding +}{ + Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")), +} + +// Session is the top-level tea.Model for keld's interactive mode. It +// manages a stack of screens, renders chrome, and handles global +// navigation (back/cancel). +type Session struct { + // screens holds every screen in the flow. The session keeps them + // all so back navigation can re-activate earlier screens with + // their state intact. + screens []Screen + + // cursor points to the currently active screen in the screens + // slice. + cursor int + + // styles holds the pre-computed theme. + styles *theme.Styles + + // help renders the key binding bar at the bottom. + help help.Model + + // width and height store the last known terminal dimensions. + width, height int + + // lastSize caches the most recent WindowSizeMsg so it can be + // replayed to screens activated via advance or back navigation. + lastSize *tea.WindowSizeMsg + + // done is set when the session should exit (cancel or complete). + done bool + + // completed distinguishes successful flow completion from + // cancellation. The caller checks this via Completed() to decide + // whether to execute restic. + completed bool +} + +// New creates a session with the given screens. Screens that should be +// skipped (because their value is already resolved) should simply not +// be included in the slice — the caller is responsible for building +// only the screens that need user input. +func New(screens []Screen) Session { + // Default to dark until we hear from the terminal. + sty := theme.New(true) + + h := help.New() + h.Styles = sty.Help + + // Copy the slice so mutations during forwardToScreen don't + // affect the caller's original. + owned := make([]Screen, len(screens)) + copy(owned, screens) + + return Session{ + screens: owned, + styles: &sty, + help: h, + } +} + +// Completed reports whether the user finished the entire flow +// (as opposed to cancelling with Ctrl+C or Esc on the first screen). +func (s Session) Completed() bool { + return s.completed +} + +// Init requests the terminal background colour and window size, then +// initialises the first screen. +func (s Session) Init() tea.Cmd { + cmds := []tea.Cmd{ + tea.RequestBackgroundColor, + tea.RequestWindowSize, + } + if len(s.screens) > 0 { + cmds = append(cmds, s.screens[s.cursor].Init()) + } + return tea.Batch(cmds...) +} + +// Update handles messages. Global concerns (background detection, +// resize, Ctrl+C) are handled here. Esc and all other messages are +// forwarded to the active screen. The session navigates back when +// it receives a [BackMsg] and advances when it receives a [DoneMsg]. +func (s Session) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + sty := theme.New(msg.IsDark()) + s.styles = &sty + s.help.Styles = sty.Help + return s, nil + + case tea.WindowSizeMsg: + s.width = msg.Width + s.height = msg.Height + s.lastSize = &msg + s.help.SetWidth(msg.Width) + // Forward an adjusted size to the active screen so it knows + // how much vertical space is available after chrome. + return s.forwardToScreen(s.adjustedSizeMsg()) + + case tea.KeyPressMsg: + if key.Matches(msg, keys.Quit) { + s.done = true + return s, tea.Quit + } + // All other keys, including Esc, are forwarded to the screen. + // Screens that don't handle Esc return BackCmd. + return s.forwardToScreen(msg) + + case BackMsg: + return s.navigateBack() + + case DoneMsg: + return s.advance() + } + + return s.forwardToScreen(msg) +} + +// View renders the chrome (breadcrumb, title, help bar) around the +// active screen's content. +func (s Session) View() tea.View { + if s.done || len(s.screens) == 0 { + return tea.NewView("") + } + + screen := s.screens[s.cursor] + sty := s.styles + + var b strings.Builder + + // Breadcrumb: show selections from completed screens before the + // current one. + if crumbs := s.breadcrumb(); crumbs != "" { + b.WriteString(sty.Breadcrumb.Render(crumbs)) + b.WriteString("\n") + } + + // Title of the current screen. + if title := screen.Title(); title != "" { + b.WriteString(sty.Title.Render(title)) + b.WriteString("\n\n") + } + + // Screen content. + b.WriteString(screen.View()) + + // Help bar: screen-specific bindings + global bindings. Copy + // the slice to avoid mutating the screen's backing array. + screenBindings := screen.KeyBindings() + bindings := make([]key.Binding, 0, len(screenBindings)+2) + bindings = append(bindings, screenBindings...) + bindings = append(bindings, keys.Back, keys.Quit) + b.WriteString("\n") + b.WriteString(s.help.ShortHelpView(bindings)) + b.WriteString("\n") + + return tea.NewView(b.String()) +} + +// breadcrumb builds the breadcrumb string from selections of all +// completed screens before the current one. +func (s Session) breadcrumb() string { + var parts []string + for i := 0; i < s.cursor; i++ { + if sel := s.screens[i].Selection(); sel != "" { + parts = append(parts, sel) + } + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " "+lipgloss.NewStyle().Foreground(s.styles.Accent).Render("▸")+" ") +} + +// navigateBack moves to the previous screen, or exits if already on +// the first screen. Sends the adjusted window size directly to the +// re-activated screen so it has correct dimensions immediately. +func (s Session) navigateBack() (tea.Model, tea.Cmd) { + if s.cursor <= 0 { + // On the first screen: back exits. + s.done = true + return s, tea.Quit + } + s.cursor-- + cmd := s.screens[s.cursor].Init() + if s.lastSize != nil { + updated, sizeCmd := s.screens[s.cursor].Update(s.adjustedSizeMsg()) + s.screens[s.cursor] = updated + cmd = tea.Batch(cmd, sizeCmd) + } + return s, cmd +} + +// advance moves to the next screen. If the current screen is the last +// one, the flow is complete — the session marks itself done and quits. +// Sends the adjusted window size directly to the newly activated +// screen so it has correct dimensions immediately. +func (s Session) advance() (tea.Model, tea.Cmd) { + if s.cursor >= len(s.screens)-1 { + // Last screen completed: flow is done. + s.done = true + s.completed = true + return s, tea.Quit + } + s.cursor++ + cmd := s.screens[s.cursor].Init() + if s.lastSize != nil { + updated, sizeCmd := s.screens[s.cursor].Update(s.adjustedSizeMsg()) + s.screens[s.cursor] = updated + cmd = tea.Batch(cmd, sizeCmd) + } + return s, cmd +} + +// forwardToScreen sends a message to the active screen and updates it. +func (s Session) forwardToScreen(msg tea.Msg) (tea.Model, tea.Cmd) { + if len(s.screens) == 0 { + return s, nil + } + + updated, cmd := s.screens[s.cursor].Update(msg) + s.screens[s.cursor] = updated + return s, cmd +} + +// adjustedSizeMsg returns a WindowSizeMsg with the height reduced by +// the chrome lines so screens can size themselves correctly. +func (s Session) adjustedSizeMsg() tea.WindowSizeMsg { + return tea.WindowSizeMsg{Width: s.width, Height: max(1, s.height-chromeLines)} +} diff --git a/internal/ui/session_test.go b/internal/ui/session_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cffe63dd479934337c2a3076f16ed76fb5d7b4ab --- /dev/null +++ b/internal/ui/session_test.go @@ -0,0 +1,553 @@ +package ui + +import ( + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" +) + +// fakeScreen is a minimal Screen implementation for testing session +// navigation without depending on real UI components. It returns +// BackCmd on Esc to signal back-navigation, matching the expected +// behaviour for screens that don't use Esc internally. +type fakeScreen struct { + title string + selection string + initCalls int +} + +func newFake(title string) *fakeScreen { + return &fakeScreen{title: title} +} + +func (f *fakeScreen) Init() tea.Cmd { f.initCalls++; return nil } + +func (f *fakeScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + if kp, ok := msg.(tea.KeyPressMsg); ok { + if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) { + return f, BackCmd + } + } + return f, nil +} + +func (f *fakeScreen) View() string { return f.title + " view" } +func (f *fakeScreen) Title() string { return f.title } +func (f *fakeScreen) KeyBindings() []key.Binding { return nil } +func (f *fakeScreen) Selection() string { return f.selection } + +// complete marks the fake screen as having a selection for breadcrumb +// display purposes. +func (f *fakeScreen) complete(sel string) { f.selection = sel } + +// selectingScreen completes itself on any non-Esc key press by setting +// its selection and returning DoneCmd to signal advancement. +type selectingScreen struct { + fakeScreen +} + +func newSelecting(title string) *selectingScreen { + return &selectingScreen{fakeScreen: fakeScreen{title: title}} +} + +func (s *selectingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + if kp, ok := msg.(tea.KeyPressMsg); ok { + if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) { + return s, BackCmd + } + s.selection = s.title + return s, DoneCmd + } + return s, nil +} + +// escConsumingScreen handles Esc internally (e.g. like a file picker) +// and does not return BackCmd. +type escConsumingScreen struct { + fakeScreen + escHandled bool +} + +func newEscConsuming(title string) *escConsumingScreen { + return &escConsumingScreen{fakeScreen: fakeScreen{title: title}} +} + +func (e *escConsumingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + if kp, ok := msg.(tea.KeyPressMsg); ok { + if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) { + e.escHandled = true + return e, nil // consume Esc, no BackMsg + } + } + return e, nil +} + +// sizeCaptureScreen records the height from WindowSizeMsg for testing +// that the session adjusts dimensions correctly. +type sizeCaptureScreen struct { + fakeScreen + onSize func(int) +} + +func (s *sizeCaptureScreen) Update(msg tea.Msg) (Screen, tea.Cmd) { + if wsm, ok := msg.(tea.WindowSizeMsg); ok { + if s.onSize != nil { + s.onSize(wsm.Height) + } + } + return s.fakeScreen.Update(msg) +} + +func escMsg() tea.Msg { + return tea.KeyPressMsg{Code: tea.KeyEscape} +} + +func ctrlCMsg() tea.Msg { + return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} +} + +func enterMsg() tea.Msg { + return tea.KeyPressMsg{Code: tea.KeyEnter} +} + +// resizeMsg simulates a window resize — an unrelated background message. +func resizeMsg() tea.Msg { + return tea.WindowSizeMsg{Width: 80, Height: 24} +} + +func TestSessionInit(t *testing.T) { + t.Parallel() + + a := newFake("first") + b := newFake("second") + s := New([]Screen{a, b}) + s.Init() + + if a.initCalls != 1 { + t.Errorf("first screen Init called %d times, want 1", a.initCalls) + } + if b.initCalls != 0 { + t.Errorf("second screen Init called %d times, want 0", b.initCalls) + } +} + +func TestCtrlCExits(t *testing.T) { + t.Parallel() + + s := New([]Screen{newFake("one")}) + s.Init() + + result, cmd := s.Update(ctrlCMsg()) + sess := result.(Session) + + if !sess.done { + t.Error("session should be done after Ctrl+C") + } + if cmd == nil { + t.Error("expected tea.Quit command") + } +} + +func TestEscOnFirstScreenExits(t *testing.T) { + t.Parallel() + + s := New([]Screen{newFake("one")}) + s.Init() + + // Esc is forwarded to the screen, which returns BackCmd. + result, _ := s.Update(escMsg()) + // BackMsg triggers navigateBack. + result, cmd := result.(Session).Update(BackMsg{}) + sess := result.(Session) + + if !sess.done { + t.Error("Esc on first screen should exit") + } + if cmd == nil { + t.Error("expected tea.Quit command") + } +} + +func TestEscNavigatesBack(t *testing.T) { + t.Parallel() + + a := newFake("first") + b := newFake("second") + a.complete("picked") + + s := New([]Screen{a, b}) + s.cursor = 1 + + // Esc → screen returns BackCmd → session receives BackMsg. + result, _ := s.Update(escMsg()) + result, _ = result.(Session).Update(BackMsg{}) + sess := result.(Session) + + if sess.cursor != 0 { + t.Errorf("cursor = %d, want 0 after Esc", sess.cursor) + } +} + +func TestBackNavigationPreservesState(t *testing.T) { + t.Parallel() + + a := newFake("first") + b := newFake("second") + a.complete("picked") + + s := New([]Screen{a, b}) + s.cursor = 1 + + result, _ := s.Update(escMsg()) + result, _ = result.(Session).Update(BackMsg{}) + sess := result.(Session) + + screen := sess.screens[0].(*fakeScreen) + if screen.Selection() != "picked" { + t.Errorf("selection = %q, want %q after back navigation", screen.Selection(), "picked") + } +} + +func TestBackNavDoesNotReAdvance(t *testing.T) { + t.Parallel() + + a := newFake("first") + b := newFake("second") + a.complete("picked") + + s := New([]Screen{a, b}) + s.cursor = 1 + + // Navigate back to the first screen. + result, _ := s.Update(escMsg()) + result, _ = result.(Session).Update(BackMsg{}) + sess := result.(Session) + + if sess.cursor != 0 { + t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor) + } + + // Send an unrelated message (window resize). The session should + // NOT re-advance because advancement only happens via DoneMsg, + // not by polling Selection(). + result, _ = sess.Update(resizeMsg()) + sess = result.(Session) + + if sess.cursor != 0 { + t.Errorf("cursor = %d after resize on completed screen, want 0 (should not re-advance)", sess.cursor) + } +} + +func TestScreenAdvancesOnDoneMsg(t *testing.T) { + t.Parallel() + + a := newSelecting("first") + b := newFake("second") + + s := New([]Screen{a, b}) + s.Init() + + // Send a key to trigger selection on the first screen. The + // selectingScreen returns DoneCmd, which produces DoneMsg. + result, cmd := s.Update(enterMsg()) + // The cmd contains DoneCmd; execute it to produce DoneMsg. + if cmd == nil { + t.Fatal("expected DoneCmd from selecting screen") + } + // Simulate the runtime delivering the DoneMsg. + result, _ = result.(Session).Update(DoneMsg{}) + sess := result.(Session) + + if sess.cursor != 1 { + t.Errorf("cursor = %d, want 1 after DoneMsg", sess.cursor) + } + if b.initCalls != 1 { + t.Errorf("second screen Init called %d times, want 1", b.initCalls) + } +} + +func TestReAdvanceAfterBackAndNewSelection(t *testing.T) { + t.Parallel() + + a := newSelecting("first") + b := newFake("second") + + s := New([]Screen{a, b}) + s.Init() + + // Advance past the first screen. + result, _ := s.Update(enterMsg()) + result, _ = result.(Session).Update(DoneMsg{}) + sess := result.(Session) + + if sess.cursor != 1 { + t.Fatalf("cursor = %d, want 1 after first advance", sess.cursor) + } + + // Navigate back. + result, _ = sess.Update(escMsg()) + result, _ = result.(Session).Update(BackMsg{}) + sess = result.(Session) + + if sess.cursor != 0 { + t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor) + } + + // Make a new selection. This should advance again. + result, _ = sess.Update(enterMsg()) + result, _ = result.(Session).Update(DoneMsg{}) + sess = result.(Session) + + if sess.cursor != 1 { + t.Errorf("cursor = %d, want 1 after re-selection", sess.cursor) + } +} + +func TestEscConsumedByScreen(t *testing.T) { + t.Parallel() + + a := newFake("first") + b := newEscConsuming("picker") + a.complete("restore") + + s := New([]Screen{a, b}) + s.cursor = 1 + + // Esc is forwarded to the screen, which handles it internally + // and does NOT return BackCmd. + result, _ := s.Update(escMsg()) + sess := result.(Session) + + // Should still be on the same screen. + if sess.cursor != 1 { + t.Errorf("cursor = %d, want 1 (screen consumed Esc)", sess.cursor) + } + + screen := sess.screens[1].(*escConsumingScreen) + if !screen.escHandled { + t.Error("screen should have handled Esc internally") + } +} + +func TestBreadcrumbEmpty(t *testing.T) { + t.Parallel() + + s := New([]Screen{newFake("one")}) + crumb := s.breadcrumb() + + if crumb != "" { + t.Errorf("breadcrumb on first screen = %q, want empty", crumb) + } +} + +func TestBreadcrumbShowsCompletedSelections(t *testing.T) { + t.Parallel() + + a := newFake("cmd") + b := newFake("preset") + c := newFake("details") + + a.complete("restore") + b.complete("home@cloud") + + s := New([]Screen{a, b, c}) + s.cursor = 2 + + crumb := s.breadcrumb() + if !strings.Contains(crumb, "restore") { + t.Errorf("breadcrumb missing 'restore': %q", crumb) + } + if !strings.Contains(crumb, "home@cloud") { + t.Errorf("breadcrumb missing 'home@cloud': %q", crumb) + } +} + +func TestBreadcrumbUpdatesOnBack(t *testing.T) { + t.Parallel() + + a := newFake("cmd") + b := newFake("preset") + c := newFake("details") + + a.complete("restore") + b.complete("home@cloud") + + s := New([]Screen{a, b, c}) + s.cursor = 2 + + result, _ := s.Update(escMsg()) + result, _ = result.(Session).Update(BackMsg{}) + sess := result.(Session) + + crumb := sess.breadcrumb() + if !strings.Contains(crumb, "restore") { + t.Errorf("breadcrumb after back missing 'restore': %q", crumb) + } + // On screen b now — b's selection should not be in the crumb + // because the breadcrumb only shows screens before the current one. + if strings.Contains(crumb, "home@cloud") { + t.Errorf("breadcrumb after back should not contain current screen's selection: %q", crumb) + } +} + +func TestViewIncludesChrome(t *testing.T) { + t.Parallel() + + a := newFake("first") + a.complete("restore") + b := newFake("Second Step") + + s := New([]Screen{a, b}) + s.cursor = 1 + s.width = 80 + s.height = 24 + + view := s.View() + + if !strings.Contains(view.Content, "Second Step") { + t.Error("view should contain the screen title") + } + if !strings.Contains(view.Content, "restore") { + t.Error("view should contain the breadcrumb") + } + if !strings.Contains(view.Content, "esc") { + t.Error("view should contain the help bar with esc binding") + } +} + +func TestViewEmptyScreens(t *testing.T) { + t.Parallel() + + s := New([]Screen{}) + view := s.View() + if view.Content != "" { + t.Errorf("view with no screens should be empty, got %q", view.Content) + } +} + +func TestBackNavReInitsScreen(t *testing.T) { + t.Parallel() + + a := newFake("first") + b := newFake("second") + a.complete("cmd") + + s := New([]Screen{a, b}) + s.cursor = 1 + + result, _ := s.Update(escMsg()) + result.(Session).Update(BackMsg{}) + + // After navigating back, the first screen should be re-inited. + if a.initCalls != 1 { + t.Errorf("first screen Init called %d times after back nav, want 1", a.initCalls) + } +} + +func TestDoneOnLastScreenCompletes(t *testing.T) { + t.Parallel() + + a := newSelecting("only") + s := New([]Screen{a}) + s.Init() + + // Select on the only screen — DoneMsg should complete the session. + result, _ := s.Update(enterMsg()) + result, cmd := result.(Session).Update(DoneMsg{}) + sess := result.(Session) + + if !sess.done { + t.Error("session should be done after DoneMsg on last screen") + } + if !sess.Completed() { + t.Error("session should be completed (not cancelled)") + } + if cmd == nil { + t.Error("expected tea.Quit command") + } +} + +func TestCtrlCIsNotCompleted(t *testing.T) { + t.Parallel() + + s := New([]Screen{newFake("one")}) + s.Init() + + result, _ := s.Update(ctrlCMsg()) + sess := result.(Session) + + if !sess.done { + t.Error("session should be done after Ctrl+C") + } + if sess.Completed() { + t.Error("session should NOT be completed after cancellation") + } +} + +func TestEscExitIsNotCompleted(t *testing.T) { + t.Parallel() + + s := New([]Screen{newFake("one")}) + s.Init() + + result, _ := s.Update(escMsg()) + result, _ = result.(Session).Update(BackMsg{}) + sess := result.(Session) + + if !sess.done { + t.Error("session should be done after Esc on first screen") + } + if sess.Completed() { + t.Error("session should NOT be completed after Esc exit") + } +} + +func TestWindowSizeAdjustedForChrome(t *testing.T) { + t.Parallel() + + var receivedHeight int + screen := &sizeCaptureScreen{ + fakeScreen: fakeScreen{title: "test"}, + onSize: func(h int) { receivedHeight = h }, + } + + s := New([]Screen{screen}) + s.Init() + + s.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + + wantHeight := 30 - chromeLines + if receivedHeight != wantHeight { + t.Errorf("screen received height %d, want %d (terminal height minus chrome)", receivedHeight, wantHeight) + } +} + +func TestSizeReplayedOnAdvance(t *testing.T) { + t.Parallel() + + var receivedHeight int + a := newSelecting("first") + b := &sizeCaptureScreen{ + fakeScreen: fakeScreen{title: "second"}, + onSize: func(h int) { receivedHeight = h }, + } + + s := New([]Screen{a, b}) + s.Init() + + // Set terminal size so the session caches it. + result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + sess := result.(Session) + + // Advance to second screen. The session sends the adjusted size + // directly to the new screen during advance(). + result, _ = sess.Update(enterMsg()) + result.(Session).Update(DoneMsg{}) + + wantHeight := 30 - chromeLines + if receivedHeight != wantHeight { + t.Errorf("second screen received height %d after advance, want %d", receivedHeight, wantHeight) + } +}