From 6bdeca245a3805758102947324ad8e1c6a57f496 Mon Sep 17 00:00:00 2001 From: nicodes Date: Fri, 11 Sep 2026 17:31:18 -0600 Subject: [PATCH] Align the game page and library navigation with the web Keep play on the game screen under a two-column hero, list likes and library empty states in navigation, and drop implied key labels from the footer. --- README.md | 30 +- docs/tui.md | 62 +- internal/registry/catalog_test.go | 52 ++ internal/registry/client.go | 49 +- internal/registry/contract/catalog.json | 3 +- internal/registry/contract/game.json | 3 +- internal/registry/contract/library.json | 1 + internal/registry/device.go | 19 + internal/registry/device_test.go | 16 + internal/shell/menu.go | 2 +- internal/shell/product.go | 1021 ++++++++++++++++++----- internal/shell/product_search.go | 172 ++++ internal/shell/product_search_test.go | 138 +++ internal/shell/product_service.go | 1 + internal/shell/product_signin.go | 164 ++++ internal/shell/product_signin_test.go | 234 ++++++ internal/shell/product_test.go | 791 +++++++++++++++++- internal/shell/shell.go | 2 +- internal/shell/shell_test.go | 3 + product_service.go | 39 +- product_service_test.go | 6 +- 21 files changed, 2534 insertions(+), 274 deletions(-) create mode 100644 internal/shell/product_search.go create mode 100644 internal/shell/product_search_test.go create mode 100644 internal/shell/product_signin.go create mode 100644 internal/shell/product_signin_test.go diff --git a/README.md b/README.md index e8362c5..62c8762 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,12 @@ the auto-repeat heuristic. | Key | Action | | --- | --- | -| Tab / Shift+Tab | focus navigation, page content, or actions; move between form controls | +| Ctrl+N | toggle navigation | +| Tab | next item, action, form control, or dialog button | +| Shift+Tab | previous item, action, form control, or dialog button | +| R / P / A / U | Refresh / Play / Add / Uninstall the selected game | +| / (Marketplace/Library) | jump to the Search row | +| Ctrl+U (search) | clear the current query | | ↑/↓ | select a row or pause-menu item | | ←/→ (actions) | select the focused action | | enter | activate a focused item/button; ordinary game rows open details | @@ -94,10 +99,11 @@ the auto-repeat heuristic. | Ctrl+C | quit and save local scores | While a game runs, game inputs cannot operate the sidebar. Pause first, then -Tab into navigation. The sidebar is 22 columns wide at 120+ terminal columns; -below that it overlays the content on demand. Non-game content is capped at -100 columns. Gameplay keeps its fixed cell footprint and hides the sidebar -when needed to fit. See [TUI workflows](docs/tui.md) for the complete contract. +Ctrl+N into navigation. When open, the 22-column sidebar pushes content at 120+ +terminal columns and overlays it on smaller terminals. Closing it restores the +full content area. Non-game content is capped at 100 columns. Gameplay keeps +its fixed cell footprint; navigation overlays rather than squeezing a game that +would not fit beside it. See [TUI workflows](docs/tui.md) for the complete contract. High scores persist to `~/.config/termcade/scores.json`, and are yours whether or not you have an account — see [Your history](#your-history). @@ -109,14 +115,24 @@ Library, owner/game pages, and account/settings destinations. Browsing is anonymous. Library joins account games with installed packages, with clear installation/membership badges and Continue Playing entries. +Both lists have search by name, slug, or description (not owner). Marketplace +searches the registry after 300ms without typing and follows all result pages; +Library filters its displayed entries, including local-only games. Queries are +remembered separately until cleared or the TUI exits. Searching hides Continue +Playing, not sidebar games or account membership. Marketplace queries are limited +by the API to 64 UTF-8 bytes after trimming. + In the **TUI**, Add saves account membership without downloading. Play installs a missing compatible account game; healthy installed games remain playable offline. Remove from Library changes only account membership; Uninstall here removes only the local package, after confirmation. No automatic updates replace an existing local copy. -Sign in from Settings without exiting the TUI: it shows the trusted pairing URL -and one-time code, and opens a browser only when you choose Open browser. +Sign in from the bottom nav item or Settings without exiting the TUI: a dialog +centers the one-time code and trusted pairing URL (`/pair/ABCD-EFGH`, a +clickable terminal link) and opens a browser only when you choose Open browser. +The pair page looks up that code automatically after sign-in; approval stays +explicit. Esc or Cancel returns to the underlying screen. Account authentication stays in the browser. Settings also supports handles, org/member administration, CLI-session revocation, account deletion, and revoke-then-clear sign-out. The normal pairing destination remains diff --git a/docs/tui.md b/docs/tui.md index f7bc826..0f15ebc 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -7,14 +7,27 @@ backend behavior, game ABI, and sandbox limits are not changed by this shell. ## Navigation and focus - Marketplace is the initial destination, including while signed out. -- Tab/Shift+Tab cycles navigation, content, and actions. Arrows select within the - focused area; Enter activates. A normal game row opens details; Continue - Playing and the explicit Play action launch the game. -- At 120 or more columns the sidebar takes 22 columns. At smaller widths it is - an on-demand overlay. Non-game content is centered and capped at 100 columns. +- The navigation's Sign in / @username item is pinned at the bottom of the + rail. Navigation has no local divider or key legend; the shared footer swaps + to ↓ Tab · ↑ Shift+Tab · ✓ Enter · ✕ Esc while nav is focused. Library and installed + offline games remain accessible + while signed out; a long library scrolls without displacing the account row. +- Ctrl+N directly toggles navigation and page content, even if an action + previously had focus. It never steps through the footer. Tab is next and + Shift+Tab is previous within the focused list, action row, form, dialog, or + pause menu. They do not open or close navigation. Arrows still select + within the focused area; Enter activates. A + normal game row opens details; Continue Playing and the explicit Play action + launch the game. +- When open at 120 or more columns, navigation reserves 22 columns and pushes + the content. On smaller terminals it overlays without reflowing the content. + In both cases it stops above the main divider so page actions and keyboard + hints stay fully visible. Closed navigation reserves no width. Non-game + content stays centered and capped at 100 columns. If pushing would squeeze a + fixed-size game, navigation uses the overlay instead, even on a wide terminal. - The supported baseline is 80×24. Lists and long text scroll. Page Up/Down moves list selection with the viewport, so actions do not target a hidden row. -- During active gameplay, keys belong to the game. Esc/P pauses; Tab can then +- During active gameplay, keys belong to the game. Esc/P pauses; Ctrl+N can then focus navigation. Leaving Play closes the guest and records an abandoned run. - Pause retains Resume, Restart, pixel selection for the next start, and Leave Play. Pixel changes do not stretch or mutate an existing guest framebuffer. @@ -22,9 +35,30 @@ backend behavior, game ABI, and sandbox limits are not changed by this shell. left/right, Home/End, Backspace/Delete and Ctrl+U edit a field. Enter activates a button rather than silently submitting from a text field. - Ctrl+C quits from every state. Escape dismisses/backtracks outside gameplay. +- Product pages/states share one bottom-anchored footer that spans the full + terminal width. Its contents follow the focused context (navigation, page + actions, forms, gameplay). Keyboard hints use ↓ Tab, ↑ Shift+Tab, ✓ Enter, + and ✕ Esc. Marketplace/library game actions are keyed directly: ✕ Esc Back, + ↻ R Refresh, ▶ P Play, + A Add, and ⌫ U Uninstall. + Pause choices and game hints are in this footer rather than over the artwork. + Footer rows are reserved before content is laid out; games keep their fixed + cell dimensions and show a too-small notice rather than being cropped. ## Library and package actions +Marketplace and Library have a Search row at the top of the list. Tab/Shift+Tab +or arrows move onto it to type; `/` jumps there. Enter, Tab, or down moves to +results without clearing. Esc is still Back. Ctrl+U clears the query. Queries +survive visiting details or another destination during this TUI session. + +Marketplace uses server name/slug/description search (not owner), debounced by +300ms, and follows cursors through the full result set. An API/network failure +shows an error rather than a complete-looking partial list. The API accepts at +most 64 UTF-8 bytes after trimming. Library immediately filters its view using +the same fields when metadata is available, including installed local-only games. +Neither sidebar games nor shared membership are filtered. Continue Playing is +hidden while the query is nonblank; an empty filtered list says No matching games. + Library is an ID-keyed union of account membership and installed packages. Installed versions are shown independently of the newest registry metadata. @@ -70,6 +104,22 @@ revoke. Account changes/expiry clear cached private member/session views. ## Local development pairing +Choose the bottom Sign in item (or Sign in within Settings) to open a centered +dialog over the current screen. The dialog title is Sign in. Below it, the +pairing URL, a blank line, the pairing code, and Open browser / Cancel options +are centered. The URL is `/pair/ABCD-EFGH` so the pair page can look the device +up automatically; it remains an OSC-8 hyperlink in terminals that support +clicks. Approval in the browser is still explicit. Open browser is explicit, +and passwords/email codes are never entered in the TUI. Tab or arrows select +those options; Enter activates. Ctrl+N does nothing in the dialog. Esc +closes it without navigating away. Successful sign-in also closes +it. A paused game remains +paused and open underneath. Very small terminals show a resize notice and +accept only Esc/Ctrl+C until controls fit. Status sits above the divider; below +it the hints are ↓ Tab, ↑ Shift+Tab, ✓ Enter, and ✕ Esc. +Cancel stops waiting; if credential saving has begun, the shell reconciles +actual state because a completed save cannot be undone by canceling its result. + Use the actual API/app ports printed by `make dev` in the backend checkout: ```sh diff --git a/internal/registry/catalog_test.go b/internal/registry/catalog_test.go index e61c727..128e4b7 100644 --- a/internal/registry/catalog_test.go +++ b/internal/registry/catalog_test.go @@ -1,6 +1,7 @@ package registry import ( + "context" "encoding/json" "fmt" "net/http" @@ -9,10 +10,58 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/aviorstudio/termcade/sdk" ) +func TestSearchCatalogFullTraversal(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("q") != "blocks & é" || r.URL.Query().Get("abi") != "" { + t.Errorf("wrong search query: %s", r.URL.RawQuery) + } + requests++ + page := CatalogPage{Games: []Game{{ID: "owner/duplicate"}, {ID: fmt.Sprintf("owner/game-%d", requests)}}} + if requests == 2 { + page.Games = nil + } + if requests < 23 { + page.Next = fmt.Sprint(requests) + } + json.NewEncoder(w).Encode(page) + })) + defer server.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + games, err := New(server.URL, "").WithContext(ctx).SearchCatalog(" blocks & é ") + if err != nil || requests != 23 || len(games) != 23 { + t.Fatalf("games=%d requests=%d err=%v", len(games), requests, err) + } +} + +func TestSearchCatalogRejectsCursorCyclesAndCancellation(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + cursor := "A" + if requests%2 == 0 { + cursor = "B" + } + json.NewEncoder(w).Encode(CatalogPage{Games: []Game{{ID: "owner/partial"}}, Next: cursor}) + })) + defer server.Close() + games, err := New(server.URL, "").SearchCatalog("x") + if err == nil || games != nil || requests != 3 { + t.Fatalf("cycle returned partial success: %v %v %d", games, err, requests) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := New(server.URL, "").WithContext(ctx).SearchCatalog("x"); err == nil { + t.Fatal("canceled search succeeded") + } +} + // The catalog is paged, so browsing the marketplace is several requests. The // failure this guards against is quiet: a client that stops early shows a // short marketplace and nothing anywhere says a game is missing. @@ -179,6 +228,9 @@ func TestCatalogExampleDecodes(t *testing.T) { if game.CreatedAt == "" || game.ReleasedAt == "" { t.Errorf("timestamps did not decode: %+v", game) } + if game.Likes != 0 { + t.Errorf("recorded likes = %d, want 0", game.Likes) + } } func TestGameExampleDecodes(t *testing.T) { diff --git a/internal/registry/client.go b/internal/registry/client.go index c39a4a0..cbaf9eb 100644 --- a/internal/registry/client.go +++ b/internal/registry/client.go @@ -101,6 +101,8 @@ type Game struct { // was published; the second is absent on a game that has none. CreatedAt string `json:"created_at,omitempty"` ReleasedAt string `json:"released_at,omitempty"` + Likes int `json:"likes"` + Liked bool `json:"liked,omitempty"` } // Resolved is which release to install and what it must hash to. The registry @@ -303,7 +305,7 @@ type CatalogQuery struct { Cursor string // Limit is 1-200; zero lets the registry choose. Limit int - // Search matches a game's name, id or description. + // Search matches a game's name, slug or description (not its owner). Search string // ABI restricts to games this arcade can run. Set by Games(); the registry // treats zero as "do not filter". @@ -361,6 +363,36 @@ func (c *Client) Games() ([]Game, error) { // play. Availability belongs on each action, not in an invisible list filter. func (c *Client) Catalog() ([]Game, error) { return c.catalog(CatalogQuery{}) } +// SearchCatalog walks the full public result set, including short pages. Unlike +// the legacy Games convenience method it never silently truncates at 20 pages. +// Callers should supply a cancellable/deadlined client context. +func (c *Client) SearchCatalog(search string) ([]Game, error) { + query := CatalogQuery{Search: strings.TrimSpace(search)} + var all []Game + seen := map[string]bool{} + ids := map[string]bool{} + for { + page, err := c.CatalogPage(query) + if err != nil { + return nil, err + } + for _, game := range page.Games { + if !ids[game.ID] { + all = append(all, game) + ids[game.ID] = true + } + } + if page.Next == "" { + return all, nil + } + if seen[page.Next] { + return nil, fmt.Errorf("marketplace returned a repeated page cursor") + } + seen[page.Next] = true + query.Cursor = page.Next + } +} + func (c *Client) catalog(query CatalogQuery) ([]Game, error) { var all []Game for range maxCatalogPages { @@ -502,6 +534,21 @@ func (c *Client) LibraryRemove(author, slug string) error { return c.do(http.MethodDelete, "/v1/library/"+author+"/"+slug, nil, nil) } +type LikeState struct { + Likes int `json:"likes"` + Liked bool `json:"liked"` +} + +func (c *Client) Like(author, slug string) (LikeState, error) { + var out LikeState + return out, c.do(http.MethodPut, "/v1/games/"+author+"/"+slug+"/like", nil, &out) +} + +func (c *Client) Unlike(author, slug string) (LikeState, error) { + var out LikeState + return out, c.do(http.MethodDelete, "/v1/games/"+author+"/"+slug+"/like", nil, &out) +} + // Library lists the games on this account, newest addition first. It is the // server's copy: what you have added anywhere, not what is installed here. func (c *Client) Library() ([]Game, error) { diff --git a/internal/registry/contract/catalog.json b/internal/registry/contract/catalog.json index e3ecfc7..ba20af3 100644 --- a/internal/registry/contract/catalog.json +++ b/internal/registry/contract/catalog.json @@ -12,7 +12,8 @@ "sha256": "0000000000000000000000000000000000000000000000000000000000000000", "has_package": true, "created_at": "2026-07-30T09:00:00Z", - "released_at": "2026-08-01T12:00:00Z" + "released_at": "2026-08-01T12:00:00Z", + "likes": 0 } ], "next": "MQBzbHVnAGJyaWNrb3VnaABnLWJyaWNr" diff --git a/internal/registry/contract/game.json b/internal/registry/contract/game.json index 871c3ad..125a5b1 100644 --- a/internal/registry/contract/game.json +++ b/internal/registry/contract/game.json @@ -10,5 +10,6 @@ "sha256": "0000000000000000000000000000000000000000000000000000000000000000", "has_package": true, "created_at": "2026-07-30T09:00:00Z", - "released_at": "2026-08-01T12:00:00Z" + "released_at": "2026-08-01T12:00:00Z", + "likes": 0 } diff --git a/internal/registry/contract/library.json b/internal/registry/contract/library.json index 5519441..86230a6 100644 --- a/internal/registry/contract/library.json +++ b/internal/registry/contract/library.json @@ -12,6 +12,7 @@ "has_package": true, "created_at": "2026-07-30T09:00:00Z", "released_at": "2026-08-01T12:00:00Z", + "likes": 0, "activity": { "id": "aviorstudio/brickough", "personal_best": 4242, diff --git a/internal/registry/device.go b/internal/registry/device.go index e4e2928..80df487 100644 --- a/internal/registry/device.go +++ b/internal/registry/device.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "regexp" "strings" "time" @@ -59,6 +60,24 @@ type DevicePoll struct { Interval int64 `json:"interval"` } +// PairingURLWithCode appends the displayed user code as /pair/ABCD-EFGH so +// opening the link can look the device up. Invalid codes are not attached. +func PairingURLWithCode(base, code string) string { + code = strings.ToUpper(strings.TrimSpace(code)) + if base == "" || !userCodeRE.MatchString(code) { + return base + } + parsed, err := url.Parse(base) + if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return base + } + if strings.TrimRight(parsed.Path, "/") != "/pair" { + return base + } + parsed.Path = "/pair/" + code + return parsed.String() +} + func (c *Client) StartDevice(ctx context.Context, deviceName string) (DeviceRound, error) { deviceName = safeText(deviceName, 200) if deviceName == "" { diff --git a/internal/registry/device_test.go b/internal/registry/device_test.go index cb53b89..8aa6f34 100644 --- a/internal/registry/device_test.go +++ b/internal/registry/device_test.go @@ -258,3 +258,19 @@ func TestRevokedCredentialIsNotReturnedAsSession(t *testing.T) { t.Fatalf("revocation error = %v, want ErrLoginRequired", err) } } + +func TestPairingURLWithCodeAttachesOnlyAValidUserCode(t *testing.T) { + base := "https://app.termca.de/pair" + got := PairingURLWithCode(base, " abcd-efgh ") + if got != base+"/ABCD-EFGH" { + t.Fatalf("got %q", got) + } + for _, code := range []string{"", "not-a-code", "ABCD-EFGH/../x", "https://evil.example"} { + if PairingURLWithCode(base, code) != base { + t.Fatalf("attached invalid code %q", code) + } + } + if PairingURLWithCode("https://user:pass@app.termca.de/pair", "ABCD-EFGH") != "https://user:pass@app.termca.de/pair" { + t.Fatal("credentials in pairing URL were preserved into a complete link") + } +} diff --git a/internal/shell/menu.go b/internal/shell/menu.go index 50fbae9..1a1834d 100644 --- a/internal/shell/menu.go +++ b/internal/shell/menu.go @@ -10,7 +10,7 @@ import ( ) var ( - logoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#3fc4c9")) + logoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e6c945")) selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#e6c945")) normalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f2f2f2")) ) diff --git a/internal/shell/product.go b/internal/shell/product.go index 0310be1..837589e 100644 --- a/internal/shell/product.go +++ b/internal/shell/product.go @@ -22,7 +22,7 @@ const sidebarBreakpoint = 120 const productContentMax = 100 type productLocation struct{ Page, ID string } -type productItem struct{ Label, Detail, Kind, ID string } +type productItem struct{ Label, Detail, Meta, Kind, ID string } type productAction struct { Label, Kind, ID string Disabled bool @@ -35,6 +35,11 @@ type productForm struct { Confirmation string } type productState struct { + signIn *productSignInDialog + marketQuery string + searchFocus bool + searchCaret int + search productSearchState accountGen uint64 loc productLocation history []productLocation @@ -72,6 +77,7 @@ type productSyncMsg struct { func (m Model) productEnabled() bool { return m.mp != nil && m.mp.Product != nil } func (m *Model) cancelProduct() { + m.cancelProductSearch() if m.app.cancel != nil { m.app.cancel() } @@ -108,6 +114,25 @@ func (m Model) productInit() tea.Cmd { func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { switch msg := msg.(type) { + case productSearchTick: + if msg.Gen != m.app.search.gen || m.app.loc.Page != "marketplace" { + return m, nil, true + } + cmd := m.fetchProductSearch() + return m, cmd, true + case productSearchMsg: + if msg.Gen != m.app.search.gen || m.app.loc.Page != "marketplace" { + return m, nil, true + } + m.app.search.loading = false + if msg.Err != nil { + m.app.search.err = sanitize(msg.Err.Error()) + } else { + m.app.search.games = msg.Games + } + m.app.selection = 0 + m.app.action = 0 + return m, nil, true case productOpenMsg: if msg.Gen == m.app.gen && msg.Err != nil { m.app.notice = sanitize(msg.Err.Error()) @@ -184,13 +209,22 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { switch msg.Request.Kind { case "pair-start": m.app.round = msg.Reply.Round - m.app.pairingURL = msg.Reply.PairingURL + m.app.pairingURL = registry.PairingURLWithCode(msg.Reply.PairingURL, msg.Reply.Round.UserCode) cmd := m.productRequest(ProductRequest{Kind: "pair-wait", Round: msg.Reply.Round}) return m, cmd, true case "pair-wait": + if m.app.signIn != nil { + dialog := *m.app.signIn + dialog.saving = true + m.app.signIn = &dialog + } cmd := m.productRequest(ProductRequest{Kind: "save-login", Session: msg.Reply.Session}) return m, cmd, true case "save-login": + if m.app.signIn != nil { + cmd := m.closeSignInDialog(true) + return m, cmd, true + } m.app.round = registry.DeviceRound{} m.app.pairingURL = "" m.app.form = nil @@ -203,6 +237,11 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { case "install": for i, g := range m.games { if g.Info.ID == msg.Request.ID { + if m.app.loc.Page != "game" || m.app.loc.ID != msg.Request.ID { + m.app.history = append(m.app.history, m.app.loc) + m.app.loc = productLocation{Page: "game", ID: msg.Request.ID} + m.app.game = m.metadata(msg.Request.ID) + } next, cmd := m.startGame(i) return next.(Model), cmd, true } @@ -239,12 +278,18 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { } items = m.productItems() for i, item := range items { - if selected != "" && item.ID == selected && item.Kind == selectedKind { + if item.Kind == selectedKind && item.ID == selected { m.app.selection = i break } } m.app.selection = clampIndex(m.app.selection, len(items)) + if m.productSearchPage() && strings.TrimSpace(m.productQuery()) == "" && !m.app.searchFocus { + if len(items) > 1 && items[m.app.selection].Kind == "search" { + m.app.selection = 1 + } + } + m.syncSearchFocus() for i, item := range m.productNavItems() { if item.Kind == navSelection.Kind && item.ID == navSelection.ID { m.app.nav = i @@ -260,15 +305,30 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { return next, cmd, true } } + if msg.Reply.Snapshot != nil && m.app.loc.Page == "marketplace" && strings.TrimSpace(m.app.marketQuery) != "" && !m.app.search.active { + cmd := m.startProductSearch(0) + return m, cmd, true + } + if msg.Request.Kind == "game" && m.app.loc.Page == "game" && m.screen == screenProduct { + next, cmd := m.activateProduct("play", m.app.loc.ID) + return next, cmd, true + } return m, nil, true case tea.PasteMsg: + if m.app.signIn != nil { + return m, nil, true + } + if m.app.searchFocus && m.screen == screenProduct && m.app.form == nil { + cmd := m.insertProductSearch(msg.Content) + return m, cmd, true + } if m.app.form != nil && m.app.form.Index < len(m.app.form.Values) && !m.app.loading { m.appendFormText(msg.Content) } return m, nil, true case tea.KeyPressMsg: key := msg.String() - if m.screen != screenPlaying && msg.IsRepeat && (key == "enter" || key == "tab" || key == "shift+tab" || key == "esc") { + if m.screen != screenPlaying && msg.IsRepeat && (key == "enter" || key == "tab" || key == "shift+tab" || productNavToggle(msg) || key == "esc") { return m, nil, true } if key == "ctrl+c" { @@ -277,14 +337,30 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { m.scores.Save() return m, tea.Quit, true } + if m.app.signIn != nil { + return m.signInDialogKey(msg) + } if m.screen == screenPlaying { return m, nil, false } if m.screen == screenPaused || m.screen == screenGameOver || m.screen == screenCrashed { - if key == "tab" || key == "shift+tab" { + if productNavToggle(msg) { m.app.gameNav = !m.app.gameNav return m, nil, true } + if !productNavToggle(msg) && (key == "tab" || key == "shift+tab") { + if m.app.gameNav { + navKey := "down" + if key == "shift+tab" { + navKey = "up" + } + return m.productNavKey(navKey) + } + if m.screen == screenPaused { + return m.productPauseKey(key) + } + return m, nil, true + } if m.app.gameNav { if key == "esc" { m.app.gameNav = false @@ -297,9 +373,32 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { } return m, nil, false } + if productNavToggle(msg) { + m.app.searchFocus = false + if m.app.focus == 0 { + m.app.focus = 1 + } else { + m.app.focus = 0 + } + return m, nil, true + } if m.app.form != nil { return m.productFormKey(msg) } + if m.app.searchFocus { + next, cmd, handled := m.productSearchKey(msg) + if handled { + return next, cmd, true + } + m = next + } + if key == "/" && m.productSearchPage() && !m.app.mutating { + m.app.focus = 1 + m.app.selection = 0 + m.syncSearchFocus() + m.app.searchCaret = len([]rune(m.productQuery())) + return m, nil, true + } if key == "esc" { if m.app.focus == 0 && m.termW < sidebarBreakpoint { m.app.focus = 1 @@ -314,19 +413,50 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { } m.app.loc = productLocation{Page: "marketplace"} m.app.focus = 1 + if strings.TrimSpace(m.app.marketQuery) != "" { + cmd := m.startProductSearch(0) + return m, cmd, true + } return m, nil, true } if key == "tab" || key == "shift+tab" { - delta := 1 - if key == "shift+tab" { - delta = 2 + if m.app.focus == 0 { + navKey := "down" + if key == "shift+tab" { + navKey = "up" + } + return m.productNavKey(navKey) + } + if m.app.focus == 2 { + actions := m.productActions() + if len(actions) > 0 { + delta := 1 + if key == "shift+tab" { + delta = len(actions) - 1 + } + m.app.action = (m.app.action + delta) % len(actions) + } + return m, nil, true + } + items := m.productItems() + if len(items) > 0 { + delta := 1 + if key == "shift+tab" { + delta = len(items) - 1 + } + m.app.selection = (m.app.selection + delta) % len(items) + m.app.manualScroll = false + m.app.action = 0 + m.syncSearchFocus() } - m.app.focus = (m.app.focus + delta) % 3 return m, nil, true } if m.app.focus == 0 { return m.productNavKey(key) } + if next, cmd, ok := m.productActionHotkey(key); ok { + return next, cmd, true + } if m.app.focus == 2 { actions := m.productActions() if len(actions) == 0 { @@ -357,6 +487,7 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { m.app.selection = (m.app.selection + delta) % len(items) m.app.manualScroll = false m.app.action = 0 + m.syncSearchFocus() } case "pgdown", "pgup": if m.app.loc.Page != "docs" && m.app.loc.Page != "game" { @@ -367,6 +498,7 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { m.app.selection = clampIndex(m.app.selection+delta, len(items)) m.app.manualScroll = false m.app.action = 0 + m.syncSearchFocus() return m, nil, true } d := max(1, m.termH/2) @@ -387,6 +519,13 @@ func (m Model) updateProduct(msg tea.Msg) (Model, tea.Cmd, bool) { return m, nil, false } +func productNavToggle(msg tea.KeyPressMsg) bool { + if msg.Mod&tea.ModCtrl == 0 { + return false + } + return msg.Code == 'n' || msg.Code == 'N' || msg.String() == "ctrl+n" +} + func clampIndex(i, n int) int { if n == 0 { return 0 @@ -395,8 +534,16 @@ func clampIndex(i, n int) int { } func (m Model) productNavItems() []productItem { - items := []productItem{{Label: "Marketplace", Kind: "page", ID: "marketplace"}, {Label: "Docs", Kind: "page", ID: "docs"}, {Label: "Library", Kind: "page", ID: "library"}} - for _, g := range m.app.snapshot.Library { + items := []productItem{{Label: "Marketplace", Kind: "page", ID: "marketplace"}, {Label: "Docs", Kind: "page", ID: "docs"}, {Label: "Library"}} + games := m.libraryGames() + if len(games) == 0 { + label := " No games added" + if !m.app.snapshot.SignedIn { + label = " login to access your library" + } + items = append(items, productItem{Label: label}) + } + for _, g := range games { items = append(items, productItem{Label: " " + g.Name, Kind: "game", ID: g.ID}) } label := "Sign in" @@ -406,6 +553,9 @@ func (m Model) productNavItems() []productItem { label = "@" + m.app.snapshot.Account.Username } } + if !m.app.snapshot.SignedIn { + return append(items, productItem{Label: label, Kind: "sign-in"}) + } return append(items, productItem{Label: label, Kind: "page", ID: "settings"}) } @@ -418,6 +568,13 @@ func (m Model) productNavKey(key string) (Model, tea.Cmd, bool) { m.app.nav = (m.app.nav + 1) % len(items) case "enter": i := items[clampIndex(m.app.nav, len(items))] + if i.Kind == "" { + return m, nil, true + } + if i.Kind == "sign-in" { + next, cmd := m.activateProduct(i.Kind, i.ID) + return next, cmd, true + } m = m.closeProductGame() m.app.gameNav = false next, cmd := m.activateProduct(i.Kind, i.ID) @@ -427,6 +584,12 @@ func (m Model) productNavKey(key string) (Model, tea.Cmd, bool) { } func (m Model) openProduct(loc productLocation, remember bool) (Model, tea.Cmd) { + if loc.Page == "library" { + loc.Page = "marketplace" + loc.ID = "" + } + // Explicit route changes dismiss the dialog and invalidate any device poll. + m.app.signIn = nil if remember && loc != m.app.loc && m.app.loc.Page != "pairing" { m.app.history = append(m.app.history, m.app.loc) } @@ -443,6 +606,10 @@ func (m Model) openProduct(loc productLocation, remember bool) (Model, tea.Cmd) m.app.scroll = 0 m.app.manualScroll = false m.app.form = nil + m.app.searchFocus = false + if loc.Page == "marketplace" { + m.app.selection = 1 + } m.app.notice = "" m.app.days = nil var req ProductRequest @@ -450,6 +617,12 @@ func (m Model) openProduct(loc productLocation, remember bool) (Model, tea.Cmd) case "game": m.app.game = m.metadata(loc.ID) req = ProductRequest{Kind: "game", ID: loc.ID} + cmd := m.productRequest(req) + if i := m.localIndex(loc.ID); i >= 0 && m.games[i].Err == nil { + next, play := m.startGame(i) + return next.(Model), tea.Batch(cmd, play) + } + return m, cmd case "owner": m.app.owner = registry.HandleOwner{Name: loc.ID} req = ProductRequest{Kind: "owner", ID: loc.ID} @@ -475,7 +648,7 @@ func (m Model) openProduct(loc productLocation, remember bool) (Model, tea.Cmd) } func (m Model) metadata(id string) registry.Game { - for _, collection := range [][]registry.Game{m.app.snapshot.Catalog, m.app.snapshot.Library, m.app.owner.Games} { + for _, collection := range [][]registry.Game{m.app.search.games, m.app.snapshot.Catalog, m.app.snapshot.Library, m.app.owner.Games} { for _, g := range collection { if g.ID == id { return g @@ -565,39 +738,6 @@ func (m Model) libraryGames() []registry.Game { return out } -func (m Model) productRecentItems() []productItem { - type recent struct { - game registry.Game - played time.Time - best int - } - var entries []recent - for _, g := range m.libraryGames() { - played, best := m.scores.LastPlayed(g.ID), m.scores.High(g.ID) - if m.app.snapshot.SignedIn { - for _, activity := range m.app.snapshot.Activity { - if activity.ID == g.ID { - if activity.PlayedAt().After(played) { - played = activity.PlayedAt() - } - best = max(best, activity.PersonalBest) - } - } - } - local := m.localIndex(g.ID) - playable := local >= 0 && m.games[local].Err == nil - if !played.IsZero() && (playable || (local < 0 && m.inAccount(g.ID) && g.HasPackage && g.ABI == sdk.ABIVersion)) { - entries = append(entries, recent{g, played, best}) - } - } - sort.SliceStable(entries, func(i, j int) bool { return entries[i].played.After(entries[j].played) }) - var out []productItem - for _, e := range entries[:min(3, len(entries))] { - out = append(out, productItem{Label: "Continue playing: " + e.game.Name + " [Play]", Detail: fmt.Sprintf("best %d · played %s", e.best, productPlayedAgo(e.played, time.Now())), Kind: "play", ID: e.game.ID}) - } - return out -} - func productPlayedAgo(when, now time.Time) string { if when.IsZero() { return "never" @@ -631,30 +771,45 @@ func productPlayedAgo(when, now time.Time) string { return fmt.Sprintf("%.0f months ago", months) } +func likeMeta(g registry.Game) string { + mark := "♡" + if g.Liked { + mark = "♥" + } + return fmt.Sprintf("%s %d", mark, g.Likes) +} + +func productLabelLine(prefix, label, meta string, width int) string { + label = sanitize(label) + if meta == "" { + return prefix + label + } + meta = sanitize(meta) + space := width - lipgloss.Width(prefix+label) - lipgloss.Width(meta) + if space < 1 { + space = 1 + } + return prefix + label + strings.Repeat(" ", space) + meta +} + func (m Model) productItems() []productItem { var items []productItem gameRows := func(games []registry.Game) { for _, g := range games { - items = append(items, productItem{Label: g.ID + " · " + m.installationStatus(g.ID), Detail: g.Name + " · " + g.Description, Kind: "game", ID: g.ID}) + items = append(items, productItem{Label: g.ID, Detail: g.Description, Meta: likeMeta(g), Kind: "game", ID: g.ID}) } } switch m.app.loc.Page { case "marketplace": - gameRows(m.app.snapshot.Catalog) - case "library": - items = append(items, m.productRecentItems()...) - gameRows(m.libraryGames()) - case "game": - g := m.app.game - owner, _, _ := strings.Cut(g.ID, "/") - release := "No published release" - if g.HasPackage { - release = fmt.Sprintf("Release %s · ABI %d · %d×%d", g.Version, g.ABI, g.Width, g.Height) - } - items = append(items, productItem{Label: g.Name, Detail: g.Description}, productItem{Label: "@" + owner, Kind: "owner", ID: owner}, productItem{Label: "Repository", Detail: g.Repo, Kind: "open-url", ID: g.Repo}, productItem{Label: release, Detail: m.installationStatus(g.ID)}, productItem{Label: "Public play activity", Detail: playSummary(m.app.days), Kind: "chart", ID: g.ID}) - if idx := m.localIndex(g.ID); idx >= 0 && m.games[idx].Err != nil { - items = append(items, productItem{Label: "Cannot play", Detail: m.games[idx].Err.Error()}) + items = append(items, productItem{Label: "Search", Kind: "search"}) + if m.app.search.active { + gameRows(m.app.search.games) + } else if strings.TrimSpace(m.app.marketQuery) == "" { + gameRows(m.app.snapshot.Catalog) } + case "game": + // Rendered as one centered block in productPageView. Owner, repository, + // and activity stay available as footer actions. case "owner": o := m.app.owner kind := "developer" @@ -703,9 +858,9 @@ func (m Model) productItems() []productItem { items = append(items, productItem{Label: c.DeviceName + " · " + state, Detail: "expires " + c.ExpiresAt + " · last used " + c.LastUsedAt + " · created " + c.CreatedAt, Kind: "confirm-session", ID: c.ID}) } case "pairing": - items = append(items, productItem{Label: "Secure browser approval", Detail: "Never enter an account password or email verification code here."}, productItem{Label: m.app.pairingURL, Kind: "open-url", ID: m.app.pairingURL}, productItem{Label: "Pairing code: " + m.app.round.UserCode, Detail: "Waiting for approval; Esc cancels. Browser opens only on explicit selection."}) + items = append(items, productItem{Label: "Secure browser approval", Detail: "Never enter an account password or email verification code here."}, productItem{Label: m.app.pairingURL, Kind: "open-url", ID: m.app.pairingURL}, productItem{Label: "Pairing code: " + m.app.round.UserCode, Detail: "Waiting for approval. No browser is opened automatically."}) case "docs": - items = append(items, productItem{Label: "TERMCADE", Detail: "One arcade in your terminal and browser."}, productItem{Label: "Library", Detail: "Add saves account membership. Play installs a missing package. Local installations remain playable offline."}, productItem{Label: "Two removal actions", Detail: "Remove from Library changes your account. Uninstall here removes only this machine's copy."}, productItem{Label: "Build a game", Detail: "termcade dev new author/slug\ntermcade dev build\ntermcade dev install game.tcade"}, productItem{Label: "Publish", Detail: "termcade publish [asset]"}, productItem{Label: "Keyboard", Detail: "Tab/Shift+Tab focus · arrows select · Enter activate · Esc back/pause · Ctrl+C quit. PgUp/PgDown scroll long text."}, productItem{Label: "Gameplay", Detail: "Arrows/WASD to move · Space/Z action A · X action B · Enter start. Pause retains keyboard navigation and pixel selection."}) + items = append(items, productItem{Label: "TERMCADE", Detail: "One arcade in your terminal and browser."}, productItem{Label: "Library", Detail: "Games you add appear in the navigation. Play installs a missing package. Local installations remain playable offline."}, productItem{Label: "Two removal actions", Detail: "Remove from Library changes your account. Uninstall here removes only this machine's copy."}, productItem{Label: "Build a game", Detail: "termcade dev new author/slug\ntermcade dev build\ntermcade dev install game.tcade"}, productItem{Label: "Publish", Detail: "termcade publish [asset]"}, productItem{Label: "Keyboard", Detail: "Ctrl+N toggles navigation. Tab is next and Shift+Tab is previous in lists, actions, forms, and dialogs. Enter activates; Esc goes back/pauses; Ctrl+C quits. PgUp/PgDown scrolls long text."}, productItem{Label: "Gameplay", Detail: "Arrows/WASD to move · Space/Z action A · X action B · Enter start. Pause retains keyboard navigation and pixel selection."}) } return items } @@ -715,10 +870,35 @@ func playSummary(days []registry.PlayDay) string { for _, d := range days { n += d.Count } - return fmt.Sprintf("%d plays · %d UTC days this year · Enter for daily counts", n, len(days)) + "\n" + playHeatmap(days) + return fmt.Sprintf("%d plays · %d UTC days this year", n, len(days)) + "\n" + playHeatmap(days) } func playHeatmap(days []registry.PlayDay) string { + return formatPlayHeatmap(days, 54) +} + +func playChart(days []registry.PlayDay, maxWeeks int) string { + total := 0 + for _, day := range days { + total += day.Count + } + var legend strings.Builder + legend.WriteString(dimStyle.Render("Less ")) + for i, style := range playSquare { + if i > 0 { + legend.WriteByte(' ') + } + legend.WriteString(style.Render("■")) + } + legend.WriteString(dimStyle.Render(" More")) + plays := dimStyle.Render("PLAYS ") + normalStyle.Render(fmt.Sprintf("%d", total)) + grid := formatPlayHeatmap(days, maxWeeks) + width := max(lipgloss.Width(grid), lipgloss.Width(plays)+1+lipgloss.Width(legend.String())) + pad := max(1, width-lipgloss.Width(plays)-lipgloss.Width(legend.String())) + return plays + strings.Repeat(" ", pad) + legend.String() + "\n" + grid +} + +func formatPlayHeatmap(days []registry.PlayDay, maxWeeks int) string { if len(days) == 0 { return "Activity unavailable or no daily data" } @@ -726,57 +906,87 @@ func playHeatmap(days []registry.PlayDay) string { if err != nil { return "Use daily view to inspect activity" } - start = start.AddDate(0, 0, -int(start.Weekday())) byDate := map[string]int{} last := start + peak := 0 for _, day := range days { d, e := time.Parse("2006-01-02", day.Day) if e != nil { continue } byDate[day.Day] = day.Count + peak = max(peak, day.Count) + if d.Before(start) { + start = d + } if d.After(last) { last = d } } + yearStart := time.Date(last.Year(), 1, 1, 0, 0, 0, 0, time.UTC) + start = yearStart.AddDate(0, 0, -int(yearStart.Weekday())) weeks := min(54, int(last.Sub(start).Hours()/24)/7+1) + if maxWeeks > 0 { + weeks = min(weeks, maxWeeks) + } + latest := last.AddDate(0, 0, -int(last.Weekday())).AddDate(0, 0, -(weeks-1)*7) + if latest.After(start) { + start = latest + } var lines []string - for row, label := range []string{"S", "M", "T", "W", "T", "F", "S"} { - line := label + " " + for row := 0; row < 7; row++ { + var cells []string for week := 0; week < weeks; week++ { - date := start.AddDate(0, 0, week*7+row).Format("2006-01-02") - if count, ok := byDate[date]; ok { - line += playIntensity(count, days) - } else { - line += " " + date := start.AddDate(0, 0, week*7+row) + key := date.Format("2006-01-02") + if date.Before(yearStart) || date.After(last) { + cells = append(cells, " ") + continue } + cells = append(cells, playSquare[playLevel(byDate[key], peak)].Render("■")) } - lines = append(lines, line) + lines = append(lines, strings.Join(cells, " ")) } return strings.Join(lines, "\n") } -func playIntensity(count int, days []registry.PlayDay) string { - peak := 0 - for _, d := range days { - peak = max(peak, d.Count) - } - if count <= 0 { - return "░" + +var playSquare = []lipgloss.Style{ + lipgloss.NewStyle().Foreground(lipgloss.Color("#3a3a3a")), + lipgloss.NewStyle().Foreground(lipgloss.Color("#5c4e1c")), + lipgloss.NewStyle().Foreground(lipgloss.Color("#8a7429")), + lipgloss.NewStyle().Foreground(lipgloss.Color("#c4a73a")), + lipgloss.NewStyle().Foreground(lipgloss.Color("#e6c945")), +} + +func playLevel(count, peak int) int { + if count <= 0 || peak <= 0 { + return 0 } if count*4 <= peak { - return "▁" + return 1 } if count*2 <= peak { - return "▃" + return 2 } if count*4 <= peak*3 { - return "▆" + return 3 + } + return 4 +} + +func playIntensity(count int, days []registry.PlayDay) string { + peak := 0 + for _, d := range days { + peak = max(peak, d.Count) } - return "█" + return playSquare[playLevel(count, peak)].Render("■") } func (m Model) productActions() []productAction { a := []productAction{{Label: "Back", Kind: "back"}, {Label: "Refresh", Kind: "refresh"}} + if m.productSearchPage() && m.productQuery() != "" { + a = append(a, productAction{Label: "Clear search", Kind: "clear-search", Disabled: m.app.mutating}) + } if m.app.loc.Page == "pairing" { return []productAction{{Label: "Open browser", Kind: "open-url", ID: m.app.pairingURL, Disabled: m.app.pairingURL == ""}, {Label: "New code", Kind: "pair-retry", Disabled: m.app.loading}, {Label: "Cancel", Kind: "back"}} } @@ -789,7 +999,7 @@ func (m Model) productActions() []productAction { id := "" if m.app.loc.Page == "game" { id = m.app.game.ID - } else if m.app.loc.Page == "library" || m.app.loc.Page == "marketplace" || m.app.loc.Page == "owner" { + } else if m.app.loc.Page == "marketplace" || m.app.loc.Page == "owner" { items := m.productItems() if len(items) > 0 { selected := items[clampIndex(m.app.selection, len(items))] @@ -800,6 +1010,9 @@ func (m Model) productActions() []productAction { } if id != "" { g := m.metadata(id) + if m.app.loc.Page == "game" && m.app.game.ID == id { + g = m.app.game + } local := m.localIndex(id) canPlay := local >= 0 && m.games[local].Err == nil canDownload := local < 0 && m.inAccount(id) && g.HasPackage && g.ABI == sdk.ABIVersion @@ -809,9 +1022,23 @@ func (m Model) productActions() []productAction { } else { a = append(a, productAction{Label: "Add", Kind: "add", ID: id, Disabled: !m.app.snapshot.SignedIn || !m.app.snapshot.LibraryKnown}) } + if g.Liked { + a = append(a, productAction{Label: "Unlike", Kind: "unlike", ID: id, Disabled: !m.app.snapshot.SignedIn}) + } else { + a = append(a, productAction{Label: "Like", Kind: "like", ID: id, Disabled: !m.app.snapshot.SignedIn}) + } if local >= 0 { a = append(a, productAction{Label: "Uninstall here", Kind: "confirm-uninstall", ID: id}) } + if m.app.loc.Page == "game" { + if owner, _, ok := strings.Cut(id, "/"); ok && owner != "" { + a = append(a, productAction{Label: "Owner", Kind: "owner", ID: owner}) + } + if g.Repo != "" { + a = append(a, productAction{Label: "Repository", Kind: "open-url", ID: g.Repo}) + } + a = append(a, productAction{Label: "Activity", Kind: "chart", ID: id}) + } } for i := range a { if m.app.loading && a[i].Kind != "back" && a[i].Kind != "open-url" && (a[i].Kind != "play" || m.localIndex(a[i].ID) < 0 || m.app.mutating) { @@ -821,6 +1048,84 @@ func (m Model) productActions() []productAction { return a } +func (a productAction) keyHint() string { + switch a.Kind { + case "back": + return "✕ Esc Back" + case "refresh": + return "↻ R Refresh" + case "play": + return "▶ P Play" + case "add": + return "+ A Add" + case "like": + return "♡ L Like" + case "unlike": + return "♥ L Unlike" + case "confirm-uninstall": + return "⌫ U Uninstall" + case "owner": + return "@ O Owner" + case "chart": + return "▦ C Activity" + case "open-url": + return "↗ B Open" + default: + return "" + } +} + +func (m Model) productActionHotkey(key string) (Model, tea.Cmd, bool) { + want := "" + switch strings.ToLower(key) { + case "r": + want = "refresh" + case "p": + want = "play" + case "a": + want = "add" + case "l": + want = "like" + case "u": + want = "confirm-uninstall" + case "o": + want = "owner" + case "c": + want = "chart" + case "b": + want = "open-url" + default: + return m, nil, false + } + for _, a := range m.productActions() { + match := a.Kind == want || (want == "like" && a.Kind == "unlike") + if match { + if a.Disabled { + return m, nil, true + } + next, cmd := m.activateProduct(a.Kind, a.ID) + return next, cmd, true + } + } + return m, nil, false +} + +func (m Model) productActionLegend() string { + var parts []string + for _, a := range m.productActions() { + if a.Disabled { + continue + } + if hint := a.keyHint(); hint != "" { + parts = append(parts, hint) + } + } + if len(parts) == 0 { + return "" + } + return dimStyle.Render(strings.Join(parts, " · ")) +} + func (m Model) orgAdmin(name string) bool { if !m.app.snapshot.SignedIn { return false @@ -855,6 +1160,12 @@ func (m Model) activateProduct(kind, id string) (Model, tea.Cmd) { return m, nil } switch kind { + case "search": + return m, nil + case "clear-search": + m.app.searchCaret = 0 + cmd := m.setProductQuery("") + return m, cmd case "page": return m.openProduct(productLocation{Page: id}, true) case "game", "owner", "members": @@ -866,6 +1177,10 @@ func (m Model) activateProduct(kind, id string) (Model, tea.Cmd) { m.app.manualScroll = false return m, nil case "back": + if m.app.signIn != nil { + cmd := m.closeSignInDialog(false) + return m, cmd + } m.cancelProduct() if n := len(m.app.history); n > 0 { loc := m.app.history[n-1] @@ -881,23 +1196,31 @@ func (m Model) activateProduct(kind, id string) (Model, tea.Cmd) { m.app.notice = m.notice return m, cmd case "play": - if i := m.localIndex(id); i >= 0 { - if m.games[i].Err != nil { + local := m.localIndex(id) + canPlay := local >= 0 && m.games[local].Err == nil + g := m.metadata(id) + canInstall := local < 0 && m.inAccount(id) && g.HasPackage && g.ABI == sdk.ABIVersion + if !canPlay && !canInstall { + if local >= 0 { m.app.notice = "Broken package: uninstall or replace it explicitly" - return m, nil + } else { + m.app.notice = "Add a compatible published game to your Library first" } + return m, nil + } + if m.app.loc.Page != "game" || m.app.loc.ID != id { + m.app.history = append(m.app.history, m.app.loc) + m.app.loc = productLocation{Page: "game", ID: id} + m.app.game = g + } + if canPlay { m.cancelProduct() - next, cmd := m.startGame(i) + next, cmd := m.startGame(local) return next.(Model), cmd } - g := m.metadata(id) - if !m.inAccount(id) || !g.HasPackage || g.ABI != sdk.ABIVersion { - m.app.notice = "Add a compatible published game to your Library first" - return m, nil - } cmd := m.productRequest(ProductRequest{Kind: "install", ID: id}) return m, cmd - case "add", "remove": + case "add", "remove", "like", "unlike": cmd := m.productRequest(ProductRequest{Kind: kind, ID: id}) return m, cmd case "open-url": @@ -913,10 +1236,14 @@ func (m Model) activateProduct(kind, id string) (Model, tea.Cmd) { return productOpenMsg{gen, reply.Notice, err} } case "sign-in": - m.app.history = append(m.app.history, m.app.loc) - m.app.loc = productLocation{Page: "pairing"} - m.app.focus = 2 - m.app.action = 0 + if m.app.signIn != nil { + return m, nil + } + m.app.signIn = &productSignInDialog{ + focus: m.app.focus, selection: m.app.selection, action: m.app.action, + scroll: m.app.scroll, manualScroll: m.app.manualScroll, + searchFocus: m.app.searchFocus, search: m.app.search, wasLoading: m.app.loading, + } m.app.round = registry.DeviceRound{} m.app.pairingURL = "" cmd := m.productRequest(ProductRequest{Kind: "pair-start"}) @@ -1130,35 +1457,60 @@ func (m Model) closeProductGame() Model { } func (m Model) productPauseKey(key string) (Model, tea.Cmd, bool) { - choices := []string{"Resume", "Restart", "Pixel style (next start)", "Leave Play"} - switch key { - case "esc", "p": + switch strings.ToLower(key) { + case "tab", "right", "down": + m.pauseIdx = 1 + case "shift+tab", "left", "up": + m.pauseIdx = 0 + case "esc": next, cmd := m.resumeGame() return next.(Model), cmd, true - case "up": - m.pauseIdx = (m.pauseIdx + len(choices) - 1) % len(choices) - case "down": - m.pauseIdx = (m.pauseIdx + 1) % len(choices) case "enter": - switch m.pauseIdx { - case 0: + if m.pauseIdx == 0 { next, cmd := m.resumeGame() return next.(Model), cmd, true - case 1: - next, cmd := m.startGame(m.localIndex(m.game.Info().ID)) - return next.(Model), cmd, true - case 2: - next, cmd := m.cyclePixels() - return next.(Model), cmd, true - case 3: - m = m.closeProductGame() - next, cmd := m.openProduct(productLocation{Page: "library"}, false) - return next, tea.Batch(cmd, saveScores(m.scores), m.syncCmd()), true } + m = m.closeProductGame() + next, cmd := m.openProduct(productLocation{Page: "marketplace"}, false) + return next, tea.Batch(cmd, saveScores(m.scores), m.syncCmd()), true } return m, nil, true } +func (m Model) pauseDialogView() string { + if m.termW < 1 || m.termH < 1 { + return "" + } + background := m + background.screen = screenPlaying + base := strings.Split(background.productView(), "\n") + width, height := min(40, max(24, m.termW-4)), min(10, max(8, m.termH-4)) + innerWidth, innerHeight := max(1, width-4), max(1, height-2) + resume, exit := "[Resume]", "[Exit]" + if m.pauseIdx == 0 { + resume = selectedStyle.Render(resume) + } else { + exit = selectedStyle.Render(exit) + } + options := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, resume+" "+exit) + title := titleStyle.Render("Paused") + footer := productFooter(innerWidth) + body := lipgloss.Place(innerWidth, max(1, innerHeight-lipgloss.Height(title)-1-lipgloss.Height(footer)), lipgloss.Center, lipgloss.Center, options) + panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Render(productWithFooter(title+"\n"+body, footer, innerWidth, innerHeight)) + panelLines := strings.Split(boundedBlock(panel, width, height), "\n") + x, y := (m.termW-width)/2, (m.termH-height)/2 + for i := range base { + left, right := base[i], "" + if i >= y && i < y+height && i-y < len(panelLines) { + left, right = ansi.Cut(base[i], 0, x), ansi.Cut(base[i], x+width, m.termW) + base[i] = lipgloss.NewStyle().Faint(true).Render(left) + panelLines[i-y] + lipgloss.NewStyle().Faint(true).Render(right) + } else { + base[i] = lipgloss.NewStyle().Faint(true).Render(left) + } + } + return boundedBlock(strings.Join(base, "\n"), m.termW, m.termH) +} + // boundedBlock constrains both axes in display cells, including ANSI styling. func boundedBlock(s string, w, h int) string { if w <= 0 || h <= 0 { @@ -1167,18 +1519,57 @@ func boundedBlock(s string, w, h int) string { return lipgloss.NewStyle().Width(w).MaxWidth(w).Height(h).MaxHeight(h).Render(s) } +// Every product surface reserves its footer before laying out content. This +// keeps the divider and controls anchored at the bottom instead of clipping +// them when a form, navigation list, or game uses the available height. +func productFooter(width int, blocks ...string) string { + if width <= 0 { + return "" + } + var controls []string + for _, block := range blocks { + if block != "" { + controls = append(controls, block) + } + } + divider := lipgloss.NewStyle().Foreground(lipgloss.Color("#3a3a3a")).Render(strings.Repeat("─", width)) + if len(controls) == 0 { + return divider + } + return divider + "\n" + lipgloss.NewStyle().Width(width).MaxWidth(width).Render(strings.Join(controls, "\n")) +} + +func productWithFooter(content, footer string, width, height int) string { + if width <= 0 || height <= 0 { + return "" + } + bodyHeight := height - lipgloss.Height(footer) + if bodyHeight <= 0 { + return boundedBlock(footer, width, height) + } + return boundedBlock(content, width, bodyHeight) + "\n" + footer +} + func (m Model) productView() string { if m.termW < 1 || m.termH < 1 { return "" } + if m.app.signIn != nil { + return m.signInDialogView() + } + if m.screen == screenPaused { + return m.pauseDialogView() + } gameRoute := m.screen != screenProduct - showRail := m.termW >= sidebarBreakpoint + navOpen := (!gameRoute && m.app.focus == 0) || m.app.gameNav + // Closed navigation takes no space. An open rail pushes on wide terminals + // and overlays otherwise, keeping narrow content at its original width. + pushRail := navOpen && m.termW >= sidebarBreakpoint if gameRoute && m.game != nil && m.termW-sidebarWidth < m.game.Info().PixelW+2 { - showRail = false + pushRail = false } - navFocus := (!gameRoute && m.app.focus == 0) || m.app.gameNav width := m.termW - if showRail { + if pushRail { width -= sidebarWidth } contentWidth := min(width, productContentMax) @@ -1186,7 +1577,9 @@ func (m Model) productView() string { contentWidth = width } var body string - if gameRoute { + if gameRoute && m.app.loc.Page == "game" { + body = m.productGamePagePlayView(width, m.termH) + } else if gameRoute { body = m.productGameView(width, m.termH) } else if m.app.form != nil { body = m.productFormView(contentWidth, m.termH) @@ -1194,35 +1587,62 @@ func (m Model) productView() string { body = m.productPageView(contentWidth, m.termH) } body = lipgloss.PlaceHorizontal(width, lipgloss.Center, boundedBlock(body, contentWidth, m.termH)) - if navFocus && !showRail { + if navOpen && !pushRail { columns := min(sidebarWidth, m.termW) background := strings.Split(boundedBlock(body, m.termW, m.termH), "\n") - nav := strings.Split(m.productNavView(columns, m.termH), "\n") - nav[0] = boundedBlock("NAVIGATION", columns-1, 1) - for i := range background { + cut := productFooterRow(background) + navH := max(1, cut) + nav := strings.Split(m.productNavView(columns, navH), "\n") + for i := 0; i < cut && i < len(background); i++ { left := "" if i < len(nav) { left = nav[i] } background[i] = boundedBlock(left, columns-1, 1) + dimStyle.Render("│") + lipgloss.NewStyle().Faint(true).Render(ansi.Cut(background[i], columns, m.termW)) } - return boundedBlock(strings.Join(background, "\n"), m.termW, m.termH) + body = strings.Join(background[:cut], "\n") + } else if pushRail { + bodyLines := strings.Split(boundedBlock(body, width, m.termH), "\n") + cut := productFooterRow(bodyLines) + navH := max(1, cut) + nav := strings.Split(m.productNavView(sidebarWidth, navH), "\n") + var stacked []string + for i := 0; i < cut; i++ { + left := "" + if i < len(nav) { + left = nav[i] + } + stacked = append(stacked, boundedBlock(left, sidebarWidth-1, 1)+dimStyle.Render("│")+bodyLines[i]) + } + body = strings.Join(stacked, "\n") } - if showRail { - nav := strings.Split(m.productNavView(sidebarWidth, m.termH), "\n") - for i := range nav { - nav[i] += dimStyle.Render("│") + return m.withTerminalFooter(body) +} + +func (m Model) withTerminalFooter(body string) string { + footer := m.productChromeFooter(m.termW) + topH := max(0, m.termH-lipgloss.Height(footer)) + return productWithFooter(boundedBlock(body, m.termW, topH), footer, m.termW, m.termH) +} + +func productFooterRow(lines []string) int { + row := len(lines) + for i, line := range lines { + stripped := strings.TrimSpace(ansi.Strip(line)) + if stripped == "" { + continue + } + if strings.Trim(stripped, "─") == "" && strings.Count(stripped, "─") >= 8 { + row = i } - body = lipgloss.JoinHorizontal(lipgloss.Top, boundedBlock(strings.Join(nav, "\n"), sidebarWidth, m.termH), body) } - return boundedBlock(body, m.termW, m.termH) + return row } func (m Model) productNavView(width, height int) string { items := m.productNavItems() - lines := []string{lipgloss.NewStyle().Foreground(lipgloss.Color("#3fc4c9")).Bold(true).Render("TERMCADE"), ""} - start := max(0, m.app.nav-max(1, height-5)+1) - for i := start; i < len(items) && len(lines) < height-2; i++ { + contentWidth := max(0, width-1) + renderItem := func(i int) string { line := " " + sanitize(items[i].Label) active := items[i].Kind == "page" && items[i].ID == m.app.loc.Page if items[i].ID == "settings" && (m.app.loc.Page == "members" || m.app.loc.Page == "sessions" || m.app.loc.Page == "pairing") { @@ -1234,13 +1654,100 @@ func (m Model) productNavView(width, height int) string { if i == m.app.nav && ((m.screen == screenProduct && m.app.focus == 0) || m.app.gameNav) { line = selectedStyle.Render("▸ " + sanitize(items[i].Label)) } - lines = append(lines, line) + return ansi.Truncate(line, contentWidth, "…") + } + // The final item is account/sign-in. Keep its keyboard index and action, + // but reserve a bottom row for it rather than scrolling it with the library. + accountIndex := len(items) - 1 + identity := renderItem(accountIndex) + bodyHeight := max(0, height-lipgloss.Height(identity)) + lines := []string{lipgloss.NewStyle().Foreground(lipgloss.Color("#e6c945")).Bold(true).Render("TERMCADE"), ""} + visibleRows := max(0, bodyHeight-len(lines)) + start := 0 + if m.app.nav < accountIndex { + start = min(max(0, m.app.nav-visibleRows+1), max(0, accountIndex-visibleRows)) + } + for i := start; i < accountIndex && len(lines) < bodyHeight; i++ { + if items[i].Label == "Library" && items[i].Kind == "" { + divider := dimStyle.Render(strings.Repeat("─", max(1, contentWidth))) + if len(lines) < bodyHeight { + lines = append(lines, divider) + } + } + if len(lines) < bodyHeight { + lines = append(lines, renderItem(i)) + } } - lines = append(lines, "", "Tab: change focus") - return boundedBlock(strings.Join(lines, "\n"), width-1, height) + return productWithFooter(strings.Join(lines, "\n"), identity, contentWidth, height) +} + +func (m Model) productChromeFooter(width int) string { + var notice []string + if m.app.form != nil && m.app.loading { + notice = append(notice, "Working… Esc stops waiting and refreshes state") + } + msg := m.app.notice + if msg == "" && m.app.form == nil && m.screen == screenProduct { + switch m.app.loc.Page { + case "marketplace": + msg = m.app.snapshot.CatalogError + if m.app.search.active { + msg = m.app.search.err + } + case "settings": + msg = m.app.snapshot.AccountError + } + if msg == "" { + msg = m.app.snapshot.AccountError + } + } + if msg != "" { + notice = append(notice, marketNotice.Render(sanitize(msg))) + } + page := m.productPageControls(width) + if page == "" { + page = dimStyle.Render(" ") + } + return productFooter(width, append(notice, page)...) +} + +func (m Model) productPageControls(width int) string { + if m.app.form != nil { + return dimStyle.Render("Enter confirm · Esc cancel") + } + switch m.screen { + case screenPlaying: + hint := "Arrows/WASD move · Space/Z A · X B · Enter start" + if m.game != nil { + if gameHint := strings.TrimSpace(sanitize(m.game.HUD().Hint)); gameHint != "" { + hint = gameHint + } + } + return dimStyle.Render(hint + " · Esc pause · Ctrl+C quit") + case screenPaused: + return dimStyle.Render("Enter select · Esc resume") + case screenGameOver: + return dimStyle.Render("Enter play again · Esc Marketplace · Ctrl+C quit") + case screenCrashed: + return dimStyle.Render("Any key Marketplace · Ctrl+C quit") + } + var parts []string + if legend := m.productActionLegend(); legend != "" { + parts = append(parts, legend) + } + if m.app.searchFocus { + parts = append(parts, dimStyle.Render("Ctrl+U clear query")) + } + if len(parts) == 0 { + return "" + } + return strings.Join(parts, " · ") } func (m Model) productPageView(width, height int) string { + if m.app.loc.Page == "game" { + return m.productGameDetailsView(width, height) + } title := strings.ToUpper(m.app.loc.Page) if m.app.loc.ID != "" { title += " / " + sanitize(m.app.loc.ID) @@ -1249,27 +1756,8 @@ func (m Model) productPageView(width, height int) string { if m.app.loading { header += dimStyle.Render(" · loading…") } - notice := m.app.notice - if notice == "" { - switch m.app.loc.Page { - case "marketplace": - notice = m.app.snapshot.CatalogError - case "library": - notice = m.app.snapshot.LibraryError - case "settings": - notice = m.app.snapshot.AccountError - } - } - if notice == "" { - notice = m.app.snapshot.AccountError - } - footer := m.productActionView(width) + "\n" + dimStyle.Render("Tab focus · ↑/↓ select · Enter activate · Esc back · PgUp/PgDn scroll") - footerLines := strings.Count(footer, "\n") + 1 - if notice != "" { - footer = marketNotice.Render(sanitize(notice)) + "\n" + footer - footerLines++ - } - available := max(1, height-footerLines-3) + footer := m.productChromeFooter(width) + available := max(0, height-lipgloss.Height(footer)-lipgloss.Height(header)-1) items := m.productItems() var lines []string selectedLine := 0 @@ -1277,19 +1765,49 @@ func (m Model) productPageView(width, height int) string { if i == m.app.selection { selectedLine = len(lines) } - label := " " + sanitize(item.Label) - if i == m.app.selection && m.app.focus == 1 { - label = selectedStyle.Render("▸ " + sanitize(item.Label)) - } - text := lipgloss.NewStyle().Width(max(1, width-2)).Render(label) - if item.Detail != "" { - text += "\n" + lipgloss.NewStyle().PaddingLeft(2).Width(max(1, width-2)).Render(dimStyle.Render(sanitizeBlock(item.Detail))) + var text string + if item.Kind == "search" { + text = m.productSearchView(width) + } else { + prefix := " " + if i == m.app.selection && m.app.focus == 1 && !m.app.searchFocus { + prefix = "▸ " + } + label := productLabelLine(prefix, item.Label, item.Meta, max(1, width-2)) + if i == m.app.selection && m.app.focus == 1 && !m.app.searchFocus { + label = selectedStyle.Render(label) + } + text = lipgloss.NewStyle().Width(max(1, width-2)).Render(label) + if item.Detail != "" { + text += "\n" + lipgloss.NewStyle().PaddingLeft(2).Width(max(1, width-2)).Render(dimStyle.Render(sanitizeBlock(item.Detail))) + } } lines = append(lines, strings.Split(text, "\n")...) lines = append(lines, "") } + if m.productSearchPage() && strings.TrimSpace(m.productQuery()) != "" { + onlySearch := true + for _, item := range items { + if item.Kind != "search" { + onlySearch = false + break + } + } + if onlySearch { + lines = append(lines, "No matching games.") + } + } if len(lines) == 0 { lines = []string{"Nothing here yet. Use the focused actions or visit Marketplace."} + if m.productSearchPage() && strings.TrimSpace(m.productQuery()) != "" { + lines = []string{"No matching games."} + } + if m.app.loc.Page == "marketplace" && (m.app.search.loading || m.app.loading) { + lines = []string{"Searching…"} + } + if m.app.loc.Page == "marketplace" && m.app.search.err != "" { + lines = []string{"Search unavailable. Use Refresh to retry."} + } } start := max(0, selectedLine-available+2) if m.app.manualScroll { @@ -1297,12 +1815,15 @@ func (m Model) productPageView(width, height int) string { } start = min(start, max(0, len(lines)-available)) content := boundedBlock(strings.Join(lines[start:min(len(lines), start+available)], "\n"), width, available) - return boundedBlock(header+"\n\n"+content+"\n"+footer, width, height) + return productWithFooter(header+"\n\n"+content, footer, width, height) } func (m Model) productActionView(width int) string { var labels []string for i, a := range m.productActions() { + if a.keyHint() != "" { + continue + } label := "[" + a.Label + "]" if a.Disabled { label = dimStyle.Render(label) @@ -1311,7 +1832,11 @@ func (m Model) productActionView(width int) string { } labels = append(labels, label) } - return lipgloss.NewStyle().Width(max(1, width)).Render(strings.Join(labels, " ")) + joined := strings.Join(labels, " ") + if width <= 0 { + return joined + } + return lipgloss.NewStyle().Width(max(1, width)).Render(joined) } func (m Model) productFormView(width, height int) string { @@ -1335,26 +1860,7 @@ func (m Model) productFormView(width, height int) string { } lines = append(lines, dimStyle.Render(sanitize(label)), lipgloss.NewStyle().MaxWidth(max(1, width-2)).Render(prefix+value), "") } - buttons := []string{"Submit", "Cancel"} - if f.Kind == "username" { - buttons = []string{"Save handle", "Check availability", "Cancel"} - } - for i, b := range buttons { - if f.Index == len(f.Values)+i { - b = selectedStyle.Render("[" + b + "]") - } else { - b = "[" + b + "]" - } - lines = append(lines, b) - } - if m.app.loading { - lines = append(lines, "Working… Esc stops waiting and refreshes state") - } - if m.app.notice != "" { - lines = append(lines, marketNotice.Render(sanitize(m.app.notice))) - } - lines = append(lines, "", "Tab/Shift+Tab focus · Enter button · Esc cancel") - return boundedBlock(lipgloss.NewStyle().Width(width).Render(strings.Join(lines, "\n")), width, height) + return productWithFooter(lipgloss.NewStyle().Width(width).Render(strings.Join(lines, "\n")), m.productChromeFooter(width), width, height) } func sanitizeBlock(value string) string { @@ -1365,32 +1871,110 @@ func sanitizeBlock(value string) string { return strings.Join(lines, "\n") } -func (m Model) productGameView(width, height int) string { +func (m Model) productGameDetailsView(width, height int) string { + title := titleStyle.Render("GAME") + if m.app.loading { + title += dimStyle.Render(" · loading…") + } + footer := m.productChromeFooter(width) + return productWithFooter(title+"\n\n"+m.productGameHero(max(1, width), false), footer, width, height) +} + +func (m Model) productGameHero(width int, compact bool) string { + g := m.app.game + author, slug, _ := strings.Cut(g.ID, "/") + name := strings.TrimSpace("@" + author + " / " + slug) + if author == "" { + name = g.Name + if name == "" { + name = g.ID + } + } + var left []string + left = append(left, name) + if g.Repo != "" { + left = append(left, sanitize(g.Repo)) + } + if g.Description != "" { + left = append(left, dimStyle.Render(sanitizeBlock(g.Description))) + } + if idx := m.localIndex(g.ID); idx >= 0 && m.games[idx].Err != nil { + left = append(left, "Cannot play\n"+dimStyle.Render(sanitize(m.games[idx].Err.Error()))) + } + sep := "\n\n" + if compact { + sep = "\n" + } + leftBlock := strings.Join(left, sep) + gap := 2 + col := max(1, (width-gap)/2) + chart := playChart(m.app.days, max(1, col/2)) + leftCol := lipgloss.NewStyle().Width(col).MaxWidth(col).Align(lipgloss.Center).AlignVertical(lipgloss.Center).Render(leftBlock) + rightCol := lipgloss.NewStyle().Width(col).MaxWidth(col).Align(lipgloss.Center).AlignVertical(lipgloss.Center).Render(chart) + return lipgloss.JoinHorizontal(lipgloss.Center, leftCol, strings.Repeat(" ", gap), rightCol) +} + +func (m Model) productGamePagePlayView(width, height int) string { + footer := m.productChromeFooter(width) + title := titleStyle.Render("GAME") + if m.app.loading { + title += dimStyle.Render(" · loading…") + } + hero := m.productGameHero(max(1, width), height < 36) + top := title + "\n" + hero + rest := max(1, height-lipgloss.Height(footer)-lipgloss.Height(top)-1) + return productWithFooter(top+"\n"+m.productPlayfield(width, rest), footer, width, height) +} + +func (m Model) productPlayfield(width, height int) string { if m.game == nil { - return "No active game" + return "" } info := m.game.Info() - if width < info.PixelW+2 || height < info.PixelH/2+4 { - return tooSmall(info.PixelW+2, info.PixelH/2+4, width, height) + if m.screen == screenCrashed { + return titleStyle.Render("GAME STOPPED") + "\n" + sanitizeBlock(m.crash) } - frame := m.frame - if m.screen == screenPaused { - lines := []string{"PAUSED", ""} - for i, label := range []string{"Resume", "Restart", "Pixel style: " + m.shape.Name + " (next start)", "Leave Play"} { - if i == m.pauseIdx { - label = selectedStyle.Render("▸ " + label) - } - lines = append(lines, label) + hud := m.game.HUD() + var fields []string + for _, f := range hud.Fields { + value := inputStyle.Render(sanitize(f.Value)) + if f.Accent { + value = titleStyle.Render(sanitize(f.Value)) } - frame = compose(frame, borderStyle.Render(strings.Join(lines, "\n"))) + fields = append(fields, dimStyle.Render(sanitize(f.Label)+" ")+value) } - if m.screen == screenCrashed { - frame = compose(frame, crashBox(m.crash)) + fields = append(fields, dimStyle.Render(fmt.Sprintf("SCORE %d", m.game.Score()))) + status := strings.Join(fields, " ") + if m.app.notice != "" { + status += " · " + marketNotice.Render(sanitize(m.app.notice)) + } + oneLine := lipgloss.NewStyle().MaxWidth(max(1, width)) + requiredHeight := info.PixelH/2 + 1 + if width < info.PixelW+2 { + return tooSmall(info.PixelW+2, requiredHeight, width, height) + } + gameW := max(info.PixelW+2, lipgloss.Width(m.frame), 1) + playfield := lipgloss.NewStyle().Width(gameW).MaxWidth(gameW).Render(oneLine.Render(status) + "\n\n" + m.frame) + return lipgloss.NewStyle().Width(width).Height(height).MaxHeight(height).Render( + lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, playfield), + ) +} + +func (m Model) productGameView(width, height int) string { + if m.game == nil { + return productWithFooter("No active game", m.productChromeFooter(width), width, height) } + info := m.game.Info() + frame := m.frame header := inputStyle.Bold(true).Render(sanitize(info.Title)) + dimStyle.Render(fmt.Sprintf(" best %d", m.scores.High(info.ID))) if played := m.scores.LastPlayed(info.ID); !played.IsZero() { header += dimStyle.Render(" · played " + productPlayedAgo(played, time.Now())) } + footer := m.productChromeFooter(width) + if m.screen == screenCrashed { + stopped := header + "\n\n" + titleStyle.Render("GAME STOPPED") + "\n" + sanitizeBlock(m.crash) + return productWithFooter(stopped, footer, width, height) + } hud := m.game.HUD() var fields []string for _, f := range hud.Fields { @@ -1401,17 +1985,26 @@ func (m Model) productGameView(width, height int) string { fields = append(fields, dimStyle.Render(sanitize(f.Label)+" ")+value) } fields = append(fields, dimStyle.Render(fmt.Sprintf("SCORE %d", m.game.Score()))) - text := header + "\n" + frame + "\n" + strings.Join(fields, " ") + "\n" + dimStyle.Render(sanitize(hud.Hint)) if m.screen == screenGameOver { - text += "\n" + titleStyle.Render("[Enter: play again] [Esc: Library] [Tab: navigation]") + header += titleStyle.Render(" · GAME OVER") } else if m.screen == screenPaused { - text += "\n" + dimStyle.Render("↑/↓ select · Enter activate · Esc resume · Tab navigation") - } else { - footer := "Esc pause · Ctrl+C quit" - if m.app.notice != "" { - footer += " · " + sanitize(m.app.notice) - } - text += "\n" + dimStyle.Render(footer) + header += titleStyle.Render(" · PAUSED") } - return boundedBlock(text, width, height) + status := strings.Join(fields, " ") + if m.app.notice != "" { + status += " · " + marketNotice.Render(sanitize(m.app.notice)) + } + // Title stays at the top. The playfield is centered in the remaining area. + oneLine := lipgloss.NewStyle().MaxWidth(max(1, width)) + top := oneLine.Render(header) + requiredHeight := info.PixelH/2 + 2 + lipgloss.Height(footer) + bodyHeight := max(1, height-lipgloss.Height(footer)) + if width < info.PixelW+2 || height < requiredHeight { + return productWithFooter(tooSmall(info.PixelW+2, requiredHeight, width, height), footer, width, height) + } + gameW := max(info.PixelW+2, lipgloss.Width(frame), 1) + playfield := lipgloss.NewStyle().Width(gameW).MaxWidth(gameW).Render(oneLine.Render(status) + "\n\n" + frame) + rest := max(1, bodyHeight-lipgloss.Height(top)) + playfield = lipgloss.Place(width, rest, lipgloss.Center, lipgloss.Center, playfield) + return productWithFooter(top+"\n"+playfield, footer, width, height) } diff --git a/internal/shell/product_search.go b/internal/shell/product_search.go new file mode 100644 index 0000000..70025cb --- /dev/null +++ b/internal/shell/product_search.go @@ -0,0 +1,172 @@ +package shell + +import ( + "context" + "strings" + "time" + "unicode" + + tea "charm.land/bubbletea/v2" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/charmbracelet/x/ansi" +) + +const productSearchDebounce = 300 * time.Millisecond + +type productSearchState struct { + gen uint64 + cancel context.CancelFunc + active, loading bool + games []registry.Game + err string +} +type productSearchTick struct{ Gen uint64 } +type productSearchMsg struct { + Gen uint64 + Games []registry.Game + Err error +} + +func (m Model) productSearchPage() bool { + return m.app.loc.Page == "marketplace" +} + +func (m *Model) syncSearchFocus() { + m.app.searchFocus = false + if m.app.focus != 1 || !m.productSearchPage() { + return + } + items := m.productItems() + if len(items) > 0 && m.app.selection < len(items) && items[m.app.selection].Kind == "search" { + m.app.searchFocus = true + m.app.searchCaret = min(m.app.searchCaret, len([]rune(m.productQuery()))) + } +} +func (m Model) productQuery() string { + return m.app.marketQuery +} +func matchesProductSearch(game registry.Game, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + _, slug, ok := strings.Cut(game.ID, "/") + if !ok { + slug = game.ID + } + for _, field := range []string{game.Name, slug, game.Description} { + if strings.Contains(strings.ToLower(field), query) { + return true + } + } + return false +} +func (m *Model) cancelProductSearch() { + if m.app.search.cancel != nil { + m.app.search.cancel() + } + m.app.search = productSearchState{gen: m.app.search.gen + 1} +} +func (m *Model) startProductSearch(delay time.Duration) tea.Cmd { + m.cancelProductSearch() + m.app.search.active = true + m.app.search.loading = true + if len(strings.TrimSpace(m.app.marketQuery)) > 64 { + m.app.search.loading = false + m.app.search.err = "Marketplace search must be 64 UTF-8 bytes or fewer." + return nil + } + if delay == 0 { + return m.fetchProductSearch() + } + gen := m.app.search.gen + return tea.Tick(delay, func(time.Time) tea.Msg { return productSearchTick{gen} }) +} +func (m *Model) fetchProductSearch() tea.Cmd { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + m.app.search.cancel = cancel + gen, query, service := m.app.search.gen, strings.TrimSpace(m.app.marketQuery), m.mp.Product + return func() tea.Msg { + defer cancel() + reply, err := service.Request(ctx, ProductRequest{Kind: "catalog-search", Value: query}) + return productSearchMsg{gen, reply.Catalog, err} + } +} +func (m *Model) setProductQuery(value string) tea.Cmd { + old := m.productQuery() + m.app.marketQuery = value + m.app.selection, m.app.action, m.app.scroll = 0, 0, 0 + m.app.manualScroll = false + if m.app.loc.Page == "marketplace" && strings.TrimSpace(old) != strings.TrimSpace(value) { + return m.startProductSearch(productSearchDebounce) + } + return nil +} +func (m *Model) insertProductSearch(text string) tea.Cmd { + text = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, text) + runes := []rune(m.productQuery()) + i := min(m.app.searchCaret, len(runes)) + m.app.searchCaret = i + len([]rune(text)) + return m.setProductQuery(string(runes[:i]) + text + string(runes[i:])) +} +func (m Model) productSearchKey(msg tea.KeyPressMsg) (Model, tea.Cmd, bool) { + runes := []rune(m.productQuery()) + i := min(m.app.searchCaret, len(runes)) + var cmd tea.Cmd + switch msg.String() { + case "tab", "shift+tab", "up", "down", "esc", "pgup", "pgdown": + return m, nil, false + case "enter": + items := m.productItems() + if len(items) > 1 { + m.app.selection = 1 + m.syncSearchFocus() + } + return m, nil, true + case "ctrl+u": + m.app.searchCaret = 0 + cmd = m.setProductQuery("") + case "left": + m.app.searchCaret = max(0, i-1) + case "right": + m.app.searchCaret = min(len(runes), i+1) + case "home": + m.app.searchCaret = 0 + case "end": + m.app.searchCaret = len(runes) + case "backspace": + if i > 0 { + m.app.searchCaret = i - 1 + cmd = m.setProductQuery(string(runes[:i-1]) + string(runes[i:])) + } + case "delete": + if i < len(runes) { + cmd = m.setProductQuery(string(runes[:i]) + string(runes[i+1:])) + } + default: + if msg.Text != "" && msg.Mod&(tea.ModCtrl|tea.ModAlt|tea.ModSuper) == 0 { + cmd = m.insertProductSearch(msg.Text) + } + } + return m, cmd, true +} +func (m Model) productSearchView(width int) string { + query := m.productQuery() + prefix := " " + if m.app.searchFocus { + prefix = "▸ " + runes := []rune(query) + i := min(m.app.searchCaret, len(runes)) + label := prefix + "Search: " + // Keep the caret visible without letting a long query wrap the footer away. + keep := max(1, width-ansi.StringWidth(label)-1) + left := ansi.TruncateLeft(sanitize(string(runes[:i])), max(0, ansi.StringWidth(sanitize(string(runes[:i])))-keep), "") + return selectedStyle.Render(ansi.Truncate(label+left+"│"+sanitize(string(runes[i:])), width, "")) + } + if query == "" { + query = "Search…" + } + return dimStyle.Render(ansi.Truncate(prefix+"Search: "+sanitize(query), width, "…")) +} diff --git a/internal/shell/product_search_test.go b/internal/shell/product_search_test.go new file mode 100644 index 0000000..a9fbd75 --- /dev/null +++ b/internal/shell/product_search_test.go @@ -0,0 +1,138 @@ +package shell + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/charmbracelet/x/ansi" +) + +func TestProductSearchFields(t *testing.T) { + g := registry.Game{ID: "owner/slug", Name: "Puzzle", Description: "Falling blocks"} + for _, q := range []string{"", " ", " PUZZLE ", "slu", "BLOCKS"} { + if !matchesProductSearch(g, q) { + t.Fatalf("did not match %q", q) + } + } + for _, q := range []string{"owner", "missing", "puzzle falling"} { + if matchesProductSearch(g, q) { + t.Fatalf("unexpected match %q", q) + } + } +} + +func TestProductSearchTypingAndStaleReplies(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = step(t, m, productKey("/")) + if !m.app.searchFocus { + t.Fatal("/ did not focus search") + } + base := len(f.calls) + m, first := step(t, m, productKey("r")) + old := m.app.search.gen + m, last := step(t, m, productKey("e")) + if first == nil || last == nil || len(f.calls) != base { + t.Fatal("typing fetched early") + } + for _, item := range m.productItems() { + if item.Kind == "game" { + t.Fatal("typing exposed stale game actions") + } + } + m, cmd := step(t, m, productSearchTick{old}) + if cmd != nil { + t.Fatal("stale debounce triggered request") + } + m = drain(t, m, last) + if len(f.calls) != base+1 || f.calls[base].Value != "re" { + t.Fatalf("requests: %#v", f.calls[base:]) + } + m, _ = step(t, m, productSearchMsg{old, []registry.Game{{ID: "owner/stale"}}, errors.New("stale")}) + if m.app.search.err != "" || len(m.productItems()) != 4 { + t.Fatalf("stale reply changed results: %#v", m.productItems()) + } + m, _ = step(t, m, productKey("enter")) + if m.app.searchFocus || m.app.loc.Page != "marketplace" { + t.Fatal("Enter activated a result while editing") + } + m, cmd = step(t, m, productKey("esc")) + m = drain(t, m, cmd) + if m.app.marketQuery != "re" || len(m.productItems()) != 4 { + t.Fatal("root-page Escape lost the remembered search results") + } + m, _ = step(t, m, productKey("/")) + m, _ = step(t, m, productKey("esc")) + if m.app.marketQuery != "re" || m.app.loc.Page != "marketplace" { + t.Fatal("Esc cleared query or navigated") + } + m, _ = step(t, m, productKey("/")) + m, _ = step(t, m, productKey("ctrl+n")) + if m.app.searchFocus || m.app.focus != 0 { + t.Fatal("Ctrl+N did not open navigation") + } +} + +func TestProductMarketplaceSearchDoesNotChangeNavGames(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + before := len(m.productNavItems()) + m, _ = step(t, m, productKey("/")) + m, cmd := step(t, m, tea.PasteMsg{Content: " LOCAL\n"}) + m = drain(t, m, cmd) + if !m.inAccount("acme/remote") || len(m.productNavItems()) != before { + t.Fatal("search changed membership/sidebar") + } + found := false + for _, item := range m.productNavItems() { + if item.Kind == "game" && item.ID == "acme/local" { + found = true + } + } + if !found { + t.Fatal("local library game missing from navigation") + } +} + +func TestProductSearchKeepsIndentWhenSelected(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + idle := ansi.Strip(m.productSearchView(80)) + m, _ = step(t, m, productKey("/")) + focused := ansi.Strip(m.productSearchView(80)) + column := func(line string) int { + i := strings.Index(line, "Search") + if i < 0 { + return -1 + } + return ansi.StringWidth(line[:i]) + } + if got, want := column(focused), column(idle); got < 0 || got != want { + t.Fatalf("search shifted left when selected:\nidle %q\nfocused %q", idle, focused) + } +} + +func TestProductSearchValidationAndFooterFit(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + before := len(f.calls) + m, _ = step(t, m, productKey("/")) + m, cmd := step(t, m, tea.PasteMsg{Content: strings.Repeat("é", 33)}) + if cmd != nil || len(f.calls) != before || !strings.Contains(m.app.search.err, "64 UTF-8 bytes") { + t.Fatal("invalid search issued a request") + } + for _, w := range []int{40, 80, 120} { + view := ansi.Strip(m.productPageView(w, 24)) + if len(strings.Split(view, "\n")) > 24 || !strings.Contains(view, "Ctrl+U clear") { + t.Fatal("search displaced footer") + } + for _, line := range strings.Split(view, "\n") { + if ansi.StringWidth(line) > w { + t.Fatal("search overflowed width") + } + } + } +} diff --git a/internal/shell/product_service.go b/internal/shell/product_service.go index e51dd37..83e6332 100644 --- a/internal/shell/product_service.go +++ b/internal/shell/product_service.go @@ -39,6 +39,7 @@ type ProductRequest struct { } type ProductReply struct { + Catalog []registry.Game Snapshot *ProductSnapshot Game *registry.Game Owner *registry.HandleOwner diff --git a/internal/shell/product_signin.go b/internal/shell/product_signin.go new file mode 100644 index 0000000..1223602 --- /dev/null +++ b/internal/shell/product_signin.go @@ -0,0 +1,164 @@ +package shell + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/charmbracelet/x/ansi" +) + +// Pairing is a modal interaction, not another destination in page history. +// The underlying route and paused game remain in place throughout the poll. +type productSignInDialog struct { + button, focus, selection, action, scroll int + manualScroll, searchFocus, wasLoading, saving bool + search productSearchState +} + +func (m *Model) closeSignInDialog(signedIn bool) tea.Cmd { + dialog := m.app.signIn + if dialog == nil { + return nil + } + m.cancelProduct() + m.app.signIn = nil + m.app.round = registry.DeviceRound{} + m.app.pairingURL = "" + m.app.notice = "" + m.app.focus, m.app.selection, m.app.action = dialog.focus, dialog.selection, dialog.action + m.app.scroll, m.app.manualScroll, m.app.searchFocus = dialog.scroll, dialog.manualScroll, dialog.searchFocus + // Keep completed public search results, but never restore a canceled context + // or accept its generation. Restart any search that was still in flight. + gen := m.app.search.gen + m.app.search = dialog.search + m.app.search.gen, m.app.search.cancel = gen, nil + m.app.search.loading = false + m.app.selection = clampIndex(m.app.selection, len(m.productItems())) + if (dialog.wasLoading || dialog.saving) && !signedIn { + // Credential persistence may already have committed before Escape. Load + // actual state rather than promising that cancellation undid sign-in. + return m.productRequest(ProductRequest{Kind: "load", Extra: "refresh-page"}) + } + if dialog.search.loading && m.app.loc.Page == "marketplace" { + return m.startProductSearch(0) + } + return nil +} + +func (m Model) signInDialogActions() []productAction { + return []productAction{ + {Label: "Open browser", Kind: "open-url", ID: m.app.pairingURL, Disabled: m.app.pairingURL == ""}, + {Label: "Cancel", Kind: "back"}, + } +} + +func (m Model) signInDialogKey(msg tea.KeyPressMsg) (Model, tea.Cmd, bool) { + if msg.String() == "esc" { + cmd := m.closeSignInDialog(false) + return m, cmd, true + } + if productNavToggle(msg) { + return m, nil, true + } + // At sizes where the controls cannot be displayed, only dismissal/quit is + // accepted. A hidden focused button must never launch an external browser. + if m.termW < 40 || m.termH < 20 { + return m, nil, true + } + dialog := *m.app.signIn + m.app.signIn = &dialog + actions := m.signInDialogActions() + switch msg.String() { + case "tab", "right", "down": + dialog.button = (dialog.button + 1) % len(actions) + case "shift+tab", "left", "up": + dialog.button = (dialog.button + len(actions) - 1) % len(actions) + case "enter": + action := actions[clampIndex(dialog.button, len(actions))] + if !action.Disabled { + next, cmd := m.activateProduct(action.Kind, action.ID) + return next, cmd, true + } + } + return m, nil, true +} + +func (m Model) signInDialogView() string { + if m.termW < 40 || m.termH < 20 { + return boundedBlock("Sign in\nResize to at least 40×20\nEsc cancel · Ctrl+C quit", m.termW, m.termH) + } + background := m + background.app.signIn = nil + background.app.loading, background.app.mutating = false, false + background.app.notice = "" + background.app.search = m.app.signIn.search + background.app.focus, background.app.selection, background.app.action = m.app.signIn.focus, m.app.signIn.selection, m.app.signIn.action + background.app.scroll, background.app.manualScroll = m.app.signIn.scroll, m.app.signIn.manualScroll + base := strings.Split(background.productView(), "\n") + width, height := min(72, m.termW-2), min(20, m.termH-2) + innerWidth, innerHeight := width-4, height-2 + status := m.app.notice + var buttons []string + for i, action := range m.signInDialogActions() { + label := "[" + action.Label + "]" + if action.Disabled { + label = dimStyle.Render(label) + } else if i == m.app.signIn.button { + label = selectedStyle.Render(label) + } + buttons = append(buttons, label) + } + joined := strings.Join(buttons, " ") + options := lipgloss.NewStyle().Width(innerWidth).AlignHorizontal(lipgloss.Center).Render(joined) + if ansi.StringWidth(ansi.Strip(joined)) <= innerWidth { + options = lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, joined) + } + title := titleStyle.Render("Sign in") + footer := productFooter(innerWidth) + if status != "" { + footer = lipgloss.NewStyle().Width(innerWidth).MaxWidth(innerWidth).Render(marketNotice.Render(sanitize(status))) + "\n" + footer + } + bodyHeight := max(1, innerHeight-lipgloss.Height(title)-1-lipgloss.Height(footer)) + body := lipgloss.Place(innerWidth, bodyHeight, lipgloss.Center, lipgloss.Center, signInPairingBody(m.app.round.UserCode, m.app.pairingURL, options, innerWidth)) + content := title + "\n" + body + panel := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).Padding(0, 1).Render(productWithFooter(content, footer, innerWidth, innerHeight)) + panelLines := strings.Split(boundedBlock(panel, width, height), "\n") + x, y := (m.termW-width)/2, (m.termH-height)/2 + for i := range base { + left, right := base[i], "" + if i >= y && i < y+height { + left, right = ansi.Cut(base[i], 0, x), ansi.Cut(base[i], x+width, m.termW) + base[i] = lipgloss.NewStyle().Faint(true).Render(left) + panelLines[i-y] + lipgloss.NewStyle().Faint(true).Render(right) + } else { + base[i] = lipgloss.NewStyle().Faint(true).Render(left) + } + } + return boundedBlock(strings.Join(base, "\n"), m.termW, m.termH) +} + +func signInPairingBody(code, url, options string, width int) string { + code, url = sanitize(code), sanitize(url) + center := func(line string) string { + return lipgloss.PlaceHorizontal(width, lipgloss.Center, line) + } + var lines []string + if code == "" && url == "" { + lines = append(lines, center("Requesting a pairing code…")) + } + if url != "" { + visible := ansi.Truncate(url, width, "…") + lines = append(lines, center("\x1b]8;;"+url+"\x1b\\"+visible+"\x1b]8;;\x1b\\")) + } + if code != "" { + if url != "" { + lines = append(lines, "") + } + lines = append(lines, center(ansi.Truncate(code, width, "…"))) + } + if options != "" { + lines = append(lines, "", options) + } + return strings.Join(lines, "\n") +} diff --git a/internal/shell/product_signin_test.go b/internal/shell/product_signin_test.go new file mode 100644 index 0000000..6901245 --- /dev/null +++ b/internal/shell/product_signin_test.go @@ -0,0 +1,234 @@ +package shell + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/aviorstudio/termcade/internal/registry" + "github.com/charmbracelet/x/ansi" +) + +func TestProductSignInFromNavIsModalAndCancelRejectsLateApproval(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.SignedIn = false + m = drain(t, m, m.Init()) + m.app.focus = 0 + m.app.nav = len(m.productNavItems()) - 1 + loc, history, nav := m.app.loc, len(m.app.history), m.app.nav + m, start := step(t, m, productKey("enter")) + if m.app.signIn == nil || m.app.loc != loc || len(m.app.history) != history { + t.Fatal("sign-in replaced the underlying page") + } + m, poll := step(t, m, start()) + if poll == nil || m.app.pairingURL == "" { + t.Fatal("pairing did not start in dialog") + } + gen, ctx := m.app.gen, m.app.ctx + m, open := step(t, m, productKey("enter")) + m = drain(t, m, open) + if m.app.gen != gen || ctx.Err() != nil || !m.app.loading { + t.Fatal("explicit browser opening canceled device polling") + } + if f.calls[len(f.calls)-1].Kind != "open-url" { + t.Fatal("Open browser was not actionable") + } + for _, key := range []string{"tab", "shift+tab", "ctrl+n", "x", "/", "pgdown"} { + m, _ = step(t, m, productKey(key)) + } + m, _ = step(t, m, tea.PasteMsg{Content: "must-not-enter-background-search"}) + if m.app.nav != nav || m.app.focus != 0 || m.app.marketQuery != "" || m.app.loc != loc { + t.Fatal("dialog input escaped into the background") + } + if m.app.signIn == nil || m.app.signIn.button != 0 { + t.Fatal("Shift+Tab did not reverse Tab, or Ctrl+N dismissed the dialog") + } + m, close := step(t, m, productKey("esc")) + if close != nil || m.app.signIn != nil || ctx.Err() == nil || m.app.loc != loc || len(m.app.history) != history || m.app.focus != 0 { + t.Fatal("Escape did not cancel polling and return to the unchanged background") + } + m, cmd := step(t, m, productMsg{Gen: gen, Request: ProductRequest{Kind: "pair-wait"}, Reply: ProductReply{Session: registry.Session{Token: "must-not-render"}}}) + if cmd != nil || strings.Contains(view(m), "must-not-render") { + t.Fatal("dismissed dialog accepted late approval") + } +} + +func TestProductSignInDialogSuccessRestoresRouteAndSearch(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.marketQuery = "remote" + m.app.search = productSearchState{active: true, games: []registry.Game{f.snapshot.Catalog[1]}} + m.app.selection, m.app.focus = 0, 1 + loc, history := m.app.loc, len(m.app.history) + m, start := m.activateProduct("sign-in", "") + m = drain(t, m, start) + if m.app.signIn != nil || m.app.loc != loc || len(m.app.history) != history || m.app.focus != 1 { + t.Fatal("successful login did not return to its originating screen") + } + if m.app.marketQuery != "remote" || len(m.productItems()) != 2 || m.productItems()[1].ID != "acme/remote" { + t.Fatal("dialog lost existing search results") + } + if m.app.round.UserCode != "" || m.app.pairingURL != "" { + t.Fatal("completed pairing left its code visible") + } +} + +func TestProductSignInDialogCancelDuringSaveReconciles(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.SignedIn = false + m = drain(t, m, m.Init()) + m, start := m.activateProduct("sign-in", "") + m, poll := step(t, m, start()) + m, save := step(t, m, poll()) + if save == nil || !m.app.signIn.saving { + t.Fatal("approved pairing did not schedule saving") + } + // Control a save that completed, but whose result has not reached the model. + f.snapshot.SignedIn = true + late := save() + m, reconcile := step(t, m, productKey("esc")) + if reconcile == nil || m.app.signIn != nil { + t.Fatal("cancel during saving did not schedule reconciliation") + } + m, ignored := step(t, m, late) + if ignored != nil || m.app.snapshot.SignedIn { + t.Fatal("late save result was accepted") + } + m = drain(t, m, reconcile) + if !m.app.snapshot.SignedIn || m.app.loc.Page != "marketplace" { + t.Fatal("committed credentials were not reconciled on the original page") + } +} + +func TestProductSignInDialogFitsAndKeepsControlsVisible(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, start := m.activateProduct("sign-in", "") + m, _ = step(t, m, start()) + defer m.cancelProduct() + for _, size := range [][2]int{{40, 20}, {80, 24}, {120, 40}, {32, 12}} { + m.termW, m.termH = size[0], size[1] + rendered := ansi.Strip(view(m)) + lines := strings.Split(rendered, "\n") + if len(lines) > size[1] { + t.Fatal("dialog exceeded terminal height") + } + for _, line := range lines { + if ansi.StringWidth(line) > size[0] { + t.Fatal("dialog exceeded terminal width") + } + } + if size[0] >= 40 { + raw := view(m) + if !strings.Contains(raw, "\x1b]8;;http://127.0.0.1:8081/pair/ABCD-EFGH\x1b\\") { + t.Fatal("pairing URL is not a hyperlink that includes the user code") + } + for _, text := range []string{"Sign in", "ABCD-EFGH", "127.0.0.1:8081/pair", "[Open browser]", "[Cancel]"} { + if !strings.Contains(rendered, text) { + t.Fatalf("dialog at %v omitted %q", size, text) + } + } + for _, extra := range []string{"SIGN IN", "Approve this terminal", "Pairing code:", "[New code]", "Waiting for browser approval"} { + if strings.Contains(rendered, extra) { + t.Fatalf("dialog kept instructional copy %q", extra) + } + } + width, height := min(72, m.termW-2), min(20, m.termH-2) + x, y := (m.termW-width)/2, (m.termH-height)/2 + var panel []string + divider := -1 + for i, line := range lines[y : y+height] { + row := ansi.Cut(line, x, x+width) + panel = append(panel, row) + if row == "│ "+strings.Repeat("─", width-4)+" │" { + divider = i + } + } + if divider < 0 { + t.Fatal("dialog divider missing") + } + above, below := strings.Join(panel[:divider], "\n"), strings.Join(panel[divider+1:], "\n") + if !strings.Contains(panel[1], "Sign in") { + t.Fatalf("Sign in title is not at the top of the dialog: %q", panel[1]) + } + urlAt, optionAt := strings.Index(above, "127.0.0.1:8081/pair"), strings.Index(above, "[Open browser]") + codeAt := strings.LastIndex(above, "ABCD-EFGH") + if urlAt < 0 || codeAt <= urlAt || optionAt <= codeAt { + t.Fatal("dialog body is not URL, then code, then options") + } + urlLine, codeLine := strings.Count(above[:urlAt], "\n"), strings.Count(above[:codeAt], "\n") + if codeLine != urlLine+2 { + t.Fatal("missing blank line between the pairing URL and code") + } + for _, option := range []string{"[Open browser]", "[Cancel]"} { + if !strings.Contains(above, option) || strings.Contains(below, option) { + t.Fatalf("%q must be above the divider", option) + } + } + if strings.TrimSpace(below) != "" && strings.Contains(below, "↓ Tab") { + t.Fatal("global Tab/Enter/Esc instructions should be implied") + } + assertCenteredIn(t, above, "127.0.0.1:8081/pair") + var codeRow string + for _, line := range strings.Split(above, "\n") { + if strings.Contains(line, "ABCD-EFGH") && !strings.Contains(line, "pair") { + codeRow = line + break + } + } + assertCenteredIn(t, codeRow, "ABCD-EFGH") + if strings.Contains(above, "[Open browser] [Cancel]") { + assertCenteredIn(t, above, "[Open browser] [Cancel]") + } else { + assertCenteredIn(t, above, "[Open browser]") + } + } else { + if !strings.Contains(rendered, "Resize") { + t.Fatal("tiny terminal did not show safe fallback") + } + _, cmd := step(t, m, productKey("enter")) + if cmd != nil { + t.Fatal("invisible button remained actionable") + } + } + } +} + +func TestProductSignInDialogDoesNotClosePausedGame(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.SignedIn = false + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("play", "acme/local") + m, _ = step(t, m, productKey("esc")) + if m.screen != screenPaused { + t.Fatal("control: game did not pause") + } + m.app.gameNav = true + m.app.nav = len(m.productNavItems()) - 1 + game := m.game + m, _ = step(t, m, productKey("enter")) + if m.app.signIn == nil || m.game != game || m.screen != screenPaused { + t.Fatal("sign-in closed or resumed the paused game") + } + m, _ = step(t, m, productKey("esc")) + if m.app.signIn != nil || m.game != game || m.screen != screenPaused || !m.app.gameNav { + t.Fatal("dialog dismissal changed paused gameplay") + } + m = m.closeProductGame() +} + +func assertCenteredIn(t *testing.T, panel, needle string) { + t.Helper() + for _, line := range strings.Split(panel, "\n") { + idx := strings.Index(line, needle) + if idx < 0 { + continue + } + left, right := ansi.StringWidth(line[:idx]), ansi.StringWidth(line[idx+len(needle):]) + if min(left, right)*2+2 < max(left, right) { + t.Fatalf("%q is not centered: left=%d right=%d in %q", needle, left, right, line) + } + return + } + t.Fatalf("%q missing from dialog body", needle) +} diff --git a/internal/shell/product_test.go b/internal/shell/product_test.go index 629e32f..5670fef 100644 --- a/internal/shell/product_test.go +++ b/internal/shell/product_test.go @@ -59,6 +59,14 @@ func newProductFixture(t *testing.T) (Model, *productFixture) { return ProductReply{}, f.failure } switch r.Kind { + case "catalog-search": + var games []registry.Game + for _, game := range f.snapshot.Catalog { + if matchesProductSearch(game, r.Value) { + games = append(games, game) + } + } + return ProductReply{Catalog: games}, nil case "game": for _, game := range f.snapshot.Catalog { if game.ID == r.ID { @@ -74,6 +82,33 @@ func newProductFixture(t *testing.T) (Model, *productFixture) { f.snapshot.Library = append(f.snapshot.Library, game) } } + case "like", "unlike": + liked := r.Kind == "like" + for i, game := range f.snapshot.Catalog { + if game.ID == r.ID { + if liked && !game.Liked { + game.Likes++ + } + if !liked && game.Liked && game.Likes > 0 { + game.Likes-- + } + game.Liked = liked + f.snapshot.Catalog[i] = game + } + } + for i, game := range f.snapshot.Library { + if game.ID == r.ID { + game.Liked = liked + game.Likes = 0 + for _, catalog := range f.snapshot.Catalog { + if catalog.ID == r.ID { + game.Likes = catalog.Likes + break + } + } + f.snapshot.Library[i] = game + } + } case "remove": var keep []registry.Game for _, game := range f.snapshot.Library { @@ -107,6 +142,9 @@ func productKey(name string) tea.KeyPressMsg { if name == "shift+tab" { return tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift} } + if name == "ctrl+n" { + return tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl} + } if name == "ctrl+c" { return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} } @@ -117,13 +155,237 @@ func productKey(name string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r[0], Text: name} } +func TestProductCtrlNMatchesEvenWhenStringHidesTheModifier(t *testing.T) { + msg := tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl, Text: "n"} + if !productNavToggle(msg) { + t.Fatal("Ctrl+N with letter text was not recognized as the navigation toggle") + } + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.focus = 1 + m, _ = step(t, m, msg) + if m.app.focus != 0 { + t.Fatal("Ctrl+N with hidden modifier did not open navigation") + } +} + +func TestProductCtrlNTogglesNavigationWithoutFooterStop(t *testing.T) { + for _, width := range []int{80, 119, 120, 200} { + for _, initialFocus := range []int{0, 1, 2} { + t.Run(fmt.Sprintf("width-%d-focus-%d", width, initialFocus), func(t *testing.T) { + m, fixture := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 24}) + m.app.focus, m.app.selection, m.app.action = initialFocus, 1, 2 + location, generation, calls := m.app.loc, m.app.gen, len(fixture.calls) + for press := 0; press < 6; press++ { + want := 0 + if m.app.focus == 0 { + want = 1 + } + var cmd tea.Cmd + m, cmd = step(t, m, productKey("ctrl+n")) + if m.app.focus != want { + t.Fatalf("press %d: focus = %d, want %d", press+1, m.app.focus, want) + } + if cmd != nil || m.app.loc != location || m.app.selection != 1 || m.app.gen != generation || len(fixture.calls) != calls { + t.Fatal("navigation toggle changed page, selection, or service state") + } + if width < sidebarBreakpoint && strings.Contains(ansi.Strip(view(m)), "TERMCADE") != (want == 0) { + t.Fatal("compact navigation did not open/close with the toggle") + } + } + }) + } + } +} + +func TestProductShiftTabPreservesFormAndGameplayControls(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("form-org", "") + m.app.form.Index = 1 + m, _ = step(t, m, productKey("shift+tab")) + if m.app.form == nil || m.app.form.Index != 0 || m.app.focus != 1 { + t.Fatal("Shift+Tab no longer traverses form controls") + } + m, _ = step(t, m, productKey("ctrl+n")) + if m.app.form == nil || m.app.focus != 0 { + t.Fatal("Ctrl+N did not open navigation from a form") + } + m, _ = step(t, m, productKey("esc")) + m, _ = m.activateProduct("play", "acme/local") + m, _ = step(t, m, productKey("shift+tab")) + if m.screen != screenPlaying || m.app.gameNav { + t.Fatal("navigation interrupted live gameplay") + } + m, _ = step(t, m, productKey("ctrl+n")) + if m.screen != screenPlaying || m.app.gameNav { + t.Fatal("Ctrl+N interrupted live gameplay") + } + m, _ = step(t, m, productKey("esc")) + for _, want := range []bool{true, false, true, false} { + m, _ = step(t, m, productKey("ctrl+n")) + if m.app.gameNav != want || m.screen != screenPaused { + t.Fatal("paused navigation did not toggle directly") + } + } + m = m.closeProductGame() +} + +func TestProductPlainTabNeverOpensOrClosesNavigation(t *testing.T) { + for _, width := range []int{80, 120, 200} { + for _, focus := range []int{0, 1, 2} { + t.Run(fmt.Sprintf("width-%d-focus-%d", width, focus), func(t *testing.T) { + m, fixture := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 24}) + m.app.focus = focus + open, calls := focus == 0, len(fixture.calls) + for press := 0; press < 6; press++ { + var cmd tea.Cmd + key := "tab" + if press%2 == 1 { + key = "shift+tab" + } + m, cmd = step(t, m, productKey(key)) + if (m.app.focus == 0) != open || cmd != nil || len(fixture.calls) != calls { + t.Fatal("Tab/Shift+Tab changed navigation or invoked an action") + } + } + }) + } + } +} + +func TestProductTabAndShiftTabStepNextAndPrevious(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + if len(m.productItems()) < 2 { + t.Fatal("need at least two marketplace rows") + } + m.app.focus, m.app.selection = 1, 0 + m, _ = step(t, m, productKey("tab")) + if m.app.focus != 1 || m.app.selection != 1 { + t.Fatal("Tab did not move to the next content row") + } + m, _ = step(t, m, productKey("shift+tab")) + if m.app.focus != 1 || m.app.selection != 0 { + t.Fatal("Shift+Tab did not move to the previous content row") + } + m.app.focus, m.app.action = 2, 0 + m, _ = step(t, m, productKey("tab")) + if m.app.focus != 2 || m.app.action != 1 { + t.Fatal("Tab did not move to the next action") + } + m, _ = step(t, m, productKey("shift+tab")) + if m.app.focus != 2 || m.app.action != 0 { + t.Fatal("Shift+Tab did not move to the previous action") + } + m.app.focus, m.app.nav = 0, 1 + m, _ = step(t, m, productKey("tab")) + if m.app.focus != 0 || m.app.nav != 2 { + t.Fatal("Tab did not move to the next navigation item") + } + m, _ = step(t, m, productKey("shift+tab")) + if m.app.focus != 0 || m.app.nav != 1 { + t.Fatal("Shift+Tab did not move to the previous navigation item") + } + m, _ = m.activateProduct("play", "acme/local") + m, _ = step(t, m, productKey("esc")) + m, _ = step(t, m, productKey("tab")) + if m.screen != screenPaused || m.app.gameNav { + t.Fatal("Tab left pause") + } + m = m.closeProductGame() +} + +func TestProductMarketplaceKeysFireActions(t *testing.T) { + m, f := newProductFixture(t) + m = drain(t, m, m.Init()) + if !strings.Contains(ansi.Strip(view(m)), "↻ R Refresh") || strings.Contains(ansi.Strip(view(m)), "[Refresh]") { + t.Fatal("marketplace actions are not keyed in the shared footer") + } + f.calls = nil + m.app.focus = 0 + m, cmd := step(t, m, productKey("r")) + if cmd != nil || len(f.calls) != 0 { + t.Fatal("action keys leaked into navigation") + } + m.app.focus = 1 + m, cmd = step(t, m, productKey("r")) + m = drain(t, m, cmd) + if len(f.calls) == 0 || f.calls[len(f.calls)-1].Kind != "load" { + t.Fatalf("R did not refresh: %#v", f.calls) + } + for i, item := range m.productItems() { + if item.ID == "acme/remote" { + m.app.selection = i + break + } + } + f.calls = nil + m, cmd = step(t, m, productKey("p")) + m = drain(t, m, cmd) + if len(f.calls) == 0 || f.calls[len(f.calls)-1].Kind != "install" { + t.Fatalf("P did not play/install: %#v", f.calls) + } + m = m.closeProductGame() + m, cmd = m.openProduct(productLocation{Page: "marketplace"}, false) + m = drain(t, m, cmd) + for i, item := range m.productItems() { + if item.ID == "acme/unreleased" { + m.app.selection = i + break + } + } + f.calls = nil + m, cmd = step(t, m, productKey("p")) + if cmd != nil || len(f.calls) != 0 { + t.Fatal("P played a game that cannot run") + } + for i, item := range m.productItems() { + if item.ID == "acme/local" { + m.app.selection = i + break + } + } + m, _ = step(t, m, productKey("u")) + if m.app.form == nil || m.app.form.Kind != "uninstall" { + t.Fatal("U did not open uninstall confirmation") + } +} + +func TestProductPlainTabDoesNotToggleGameNavigation(t *testing.T) { + for _, state := range []screen{screenPlaying, screenPaused, screenGameOver, screenCrashed} { + for _, open := range []bool{false, true} { + if state == screenPlaying && open { + continue + } + t.Run(fmt.Sprintf("state-%d-open-%t", state, open), func(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = m.activateProduct("play", "acme/local") + m.screen, m.app.gameNav = state, open + for press := 0; press < 4; press++ { + m, _ = step(t, m, productKey("tab")) + if m.app.gameNav != open || m.screen != state { + t.Fatal("plain Tab changed game navigation or its state") + } + } + m = m.closeProductGame() + }) + } + } +} + func TestProductStartsOnMarketplaceAndDefersServiceWork(t *testing.T) { m, f := newProductFixture(t) if m.screen != screenProduct || m.app.loc.Page != "marketplace" || len(f.calls) != 0 { t.Fatal("startup contract") } m = drain(t, m, m.Init()) - if len(f.calls) != 1 || f.calls[0].Kind != "load" || len(m.productItems()) != 4 { + if len(f.calls) != 1 || f.calls[0].Kind != "load" || len(m.productItems()) != 5 { t.Fatalf("startup load: %#v", f.calls) } if !strings.Contains(view(m), "MARKETPLACE") { @@ -131,6 +393,295 @@ func TestProductStartsOnMarketplaceAndDefersServiceWork(t *testing.T) { } } +func TestProductFooterDividerStaysAboveInstructionsWithinHeight(t *testing.T) { + for _, width := range []int{80, 98, 100} { + for _, notice := range []string{"", "Request completed"} { + t.Run(fmt.Sprintf("width-%d-notice-%t", width, notice != ""), func(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.notice = notice + lines := strings.Split(ansi.Strip(m.productPageView(width, 24)), "\n") + if len(lines) > 24 { + t.Fatal("footer exceeded the terminal height") + } + divider, actions := -1, -1 + for i, line := range lines { + if line == strings.Repeat("─", width) { + divider = i + } + if strings.Contains(line, "Esc Back") { + actions = i + } + if lipgloss.Width(line) > width { + t.Fatal("footer exceeded the content width") + } + } + if divider < 0 || actions <= divider { + t.Fatalf("missing or misplaced footer: divider=%d actions=%d", divider, actions) + } + }) + } + } +} + +func assertProductBottomFooter(t *testing.T, rendered string, width, height int, controls ...string) { + t.Helper() + lines := strings.Split(ansi.Strip(rendered), "\n") + if len(lines) != height { + t.Fatalf("rendered %d rows, want %d", len(lines), height) + } + divider := -1 + for i, line := range lines { + if lipgloss.Width(line) > width { + t.Fatalf("line exceeds %d columns", width) + } + if line == strings.Repeat("─", width) { + divider = i + } + } + if divider < 0 || divider == len(lines)-1 { + t.Fatal("missing bottom divider or footer") + } + if strings.TrimSpace(lines[len(lines)-1]) == "" { + t.Fatal("controls are not anchored to the bottom") + } + above, below := strings.Join(lines[:divider], "\n"), strings.Join(lines[divider+1:], "\n") + for _, control := range controls { + if strings.Contains(above, control) || !strings.Contains(below, control) { + t.Fatalf("control %q is not exclusively below the divider", control) + } + } +} + +func TestEveryProductPageUsesTheBottomFooter(t *testing.T) { + for _, page := range []string{"marketplace", "game", "owner", "chart", "settings", "members", "sessions", "pairing", "docs"} { + t.Run(page, func(t *testing.T) { + m, fixture := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.loc = productLocation{Page: page, ID: "team"} + m.app.game = fixture.snapshot.Catalog[0] + m.app.owner = registry.HandleOwner{Name: "team"} + m.app.pairingURL = "http://127.0.0.1:8081/pair" + action := "↻ R Refresh" + if page == "pairing" { + action = "↗ B Open" + } + for _, width := range []int{80, 100} { + assertProductBottomFooter(t, m.productPageView(width, 24), width, 24, action) + } + }) + } +} + +func TestFormsAndNavigationKeepControlsBelowTheDivider(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + for _, kind := range []string{"form-org", "form-username", "confirm-account"} { + m, _ = m.activateProduct(kind, "") + assertProductBottomFooter(t, m.productFormView(80, 24), 80, 24, "Enter confirm", "Esc cancel") + m.app.form = nil + } + nav := ansi.Strip(m.productNavView(sidebarWidth, 24)) + if strings.Contains(nav, "↓ Tab") { + t.Fatal("navigation still draws a key legend") + } + assertNavLibraryDivider(t, nav) +} + +func TestProductNavigationPinsIdentityBelowTheList(t *testing.T) { + for _, signedIn := range []bool{false, true} { + for _, count := range []int{0, 40} { + t.Run(fmt.Sprintf("signed-in=%v/games=%d", signedIn, count), func(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.SignedIn = signedIn + f.snapshot.Library = nil + if signedIn { + for i := 0; i < count; i++ { + f.snapshot.Library = append(f.snapshot.Library, registry.Game{ID: fmt.Sprintf("acme/game-%02d", i), Name: fmt.Sprintf("Game %02d", i)}) + } + } + m = drain(t, m, m.Init()) + m.app.focus = 0 + items := m.productNavItems() + identity := "Sign in" + if signedIn { + identity = "@dev" + } + for _, height := range []int{12, 24, 40} { + for _, selection := range []int{0, len(items) - 2, len(items) - 1} { + m.app.nav = selection + rendered := m.productNavView(sidebarWidth, height) + lines := strings.Split(ansi.Strip(rendered), "\n") + if len(lines) != height { + t.Fatalf("rendered %d rows, want %d", len(lines), height) + } + if strings.Contains(ansi.Strip(rendered), "↓ Tab") { + t.Fatal("navigation still draws a key legend") + } + assertNavLibraryDivider(t, rendered) + if !strings.Contains(lines[height-1], identity) || strings.Count(ansi.Strip(rendered), identity) != 1 { + t.Fatal("identity did not remain in its single reserved bottom row") + } + if selection < len(items)-1 && !strings.Contains(ansi.Strip(rendered), strings.TrimSpace(items[selection].Label)) { + t.Fatal("scrolling hid the selected navigation item") + } + if selection == len(items)-1 && !strings.Contains(lines[height-1], "▸ "+identity) { + t.Fatal("pinned identity lost its focus indicator") + } + } + } + m.app.nav = 0 + m, _ = step(t, m, productKey("up")) + if m.app.nav != len(items)-1 { + t.Fatal("up did not wrap to pinned identity") + } + m, cmd := step(t, m, productKey("enter")) + m = drain(t, m, cmd) + if signedIn && m.app.loc.Page != "settings" { + t.Fatal("identity activation changed") + } + if !signedIn && m.app.loc.Page != "marketplace" { + t.Fatal("sign-in dialog navigated away from its background") + } + if !signedIn { + found := false + for _, item := range items { + if item.Kind == "game" && item.ID == "acme/local" { + found = true + } + } + if !found || m.localIndex("acme/local") < 0 { + t.Fatal("signed-out offline installation was removed from navigation") + } + } + }) + } + } +} + +type footerTestGame struct{ fakeGame } + +func (g *footerTestGame) Info() sdk.Info { + return sdk.Info{ID: "acme/footer", Title: "TETRIS", PixelW: 32, PixelH: 40} +} +func (g *footerTestGame) HUD() sdk.HUD { + return sdk.HUD{Fields: []sdk.HUDField{{Label: "SCORE", Value: "000000", Accent: true}, {Label: "LEVEL", Value: "1"}, {Label: "LINES", Value: "0"}}, Hint: "←/→ move · ↑ rotate · ↓ soft · space drop"} +} + +func TestProductPlaySitsBelowTheGameHero(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.days = []registry.PlayDay{{Day: "2026-01-01", Count: 1}} + m, _ = m.activateProduct("play", "acme/local") + page := ansi.Strip(m.View().Content) + if m.app.loc.Page != "game" || m.screen != screenPlaying { + t.Fatal("play did not stay on the game page") + } + if !strings.Contains(page, "@acme / local") { + t.Fatalf("playing view lost the hero:\n%s", page) + } + nameAt, scoreAt := -1, -1 + for i, line := range strings.Split(page, "\n") { + if nameAt < 0 && strings.Contains(line, "@acme / local") { + nameAt = i + } + if strings.Contains(line, "SCORE") { + scoreAt = i + } + } + if nameAt < 0 || scoreAt <= nameAt { + t.Fatalf("playfield is not below the hero: name=%d score=%d\n%s", nameAt, scoreAt, page) + } + m = m.closeProductGame() +} + +func TestGameplayStatesKeepBottomControlsAndFitAn80By24Terminal(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + g := &footerTestGame{} + m.games = []engine.Registration{{Info: g.Info(), New: func(sdk.CellShape) (sdk.Game, error) { return g, nil }}} + m.shape = sdk.Sextant // longest pixel-mode label in the pause controls + m, _ = m.activateProduct("play", g.Info().ID) + for _, state := range []struct { + screen screen + controls []string + }{ + {screenPlaying, []string{"space drop", "Esc pause", "Ctrl+C quit"}}, + {screenPaused, []string{"Enter select", "Esc resume"}}, + {screenGameOver, []string{"Enter play again", "Esc Marketplace"}}, + {screenCrashed, []string{"Any key Marketplace"}}, + } { + candidate := m + candidate.screen, candidate.crash = state.screen, "fixture failure" + rendered := candidate.productGameView(80, 24) + if strings.Contains(rendered, "Terminal too small") { + t.Fatalf("state %v no longer fits the standard playfield", state.screen) + } + assertProductBottomFooter(t, rendered, 80, 24, state.controls...) + } + tooSmallView := m.productGameView(80, 23) + if !strings.Contains(tooSmallView, "Terminal too small") { + t.Fatal("insufficient room should not crop the game") + } + assertProductBottomFooter(t, tooSmallView, 80, 23, "space drop", "Esc pause") + assertProductBottomFooter(t, (Model{}).productGameView(80, 24), 80, 24, "✕ Esc Back") + m = m.closeProductGame() +} + +func TestProductGamePageMatchesWebHero(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m.app.loc = productLocation{Page: "game", ID: "acme/local"} + m.app.game = m.metadata("acme/local") + m.app.game.Description = "A local toy" + m.app.game.Repo = "https://example.test/local" + m.app.days = []registry.PlayDay{{Day: "2026-01-01", Count: 2}, {Day: "2026-01-08", Count: 4}} + page := ansi.Strip(m.productPageView(80, 24)) + if strings.Contains(page, "GAME /") || strings.Count(page, "@acme") != 1 { + t.Fatalf("username should appear once as the name, not in the page title:\n%s", page) + } + if strings.Contains(page, "▸") || strings.Contains(page, "Public play activity") { + t.Fatalf("game page is still a stacked list:\n%s", page) + } + if !strings.Contains(page, "@acme / local") || !strings.Contains(page, "https://example.test/local") || !strings.Contains(page, "A local toy") { + t.Fatalf("left column missing name/url/desc:\n%s", page) + } + sideBySide := false + for _, line := range strings.Split(page, "\n") { + hasLeft := strings.Contains(line, "@acme") || strings.Contains(line, "example.test") || strings.Contains(line, "A local toy") + hasChart := strings.Contains(line, "PLAYS") || strings.Contains(line, "■") + if hasLeft && hasChart { + sideBySide = true + break + } + } + if !sideBySide { + t.Fatalf("chart is not to the right of name/url/desc:\n%s", page) + } + floated, centered := false, false + for _, line := range strings.Split(page, "\n") { + stripped := strings.TrimRight(ansi.Strip(line), " ") + if strings.Contains(stripped, "PLAYS") && strings.Contains(stripped, "More") { + floated = strings.HasSuffix(stripped, "More") && strings.Index(stripped, "PLAYS") < strings.Index(stripped, "Less") + } + if at := strings.Index(ansi.Strip(line), "@acme / local"); at >= 4 { + centered = true + } + } + if !floated || !centered { + t.Fatalf("legend should float right and name/url/desc should be centered:\n%s", page) + } + var owner, repo, activity bool + for _, a := range m.productActions() { + owner = owner || (a.Kind == "owner" && a.ID == "acme") + repo = repo || (a.Kind == "open-url" && a.ID == m.app.game.Repo) + activity = activity || (a.Kind == "chart" && a.ID == "acme/local") + } + if !owner || !repo || !activity { + t.Fatalf("game page lost owner/repo/activity actions: %#v", m.productActions()) + } +} + func TestProductEnterOpensDetailsAndAddDoesNotInstall(t *testing.T) { m, f := newProductFixture(t) m = drain(t, m, m.Init()) @@ -207,7 +758,7 @@ func TestProductUnknownMembershipAndOfflinePlay(t *testing.T) { if m.screen != screenPaused { t.Fatal("pause missing") } - m, _ = step(t, m, productKey("tab")) + m, _ = step(t, m, productKey("ctrl+n")) if !m.app.gameNav { t.Fatal("paused navigation unavailable") } @@ -308,7 +859,7 @@ func TestProductUnicodeEditingAndLongFieldsStayBounded(t *testing.T) { t.Fatal("field exceeded terminal width") } } - if !strings.Contains(view(m), "Submit") { + if !strings.Contains(view(m), "Enter confirm") { t.Fatal("long field hid submission controls") } } @@ -319,9 +870,12 @@ func TestProductHeatmapPreservesZeroDaysAndIntensity(t *testing.T) { if len(strings.Split(heatmap, "\n")) != 7 { t.Fatal("week rows missing") } - for _, glyph := range []string{"░", "▁", "▃", "▆", "█"} { - if !strings.Contains(heatmap, glyph) { - t.Fatal("missing activity level", glyph) + if strings.ContainsAny(ansi.Strip(heatmap), "░▁▃▆█") || !strings.Contains(heatmap, "■") { + t.Fatal("heatmap should use colored squares, not bar glyphs") + } + for _, count := range []int{0, 1, 2, 3, 4} { + if playLevel(count, 4) != count { + t.Fatal("missing activity level", count) } } if !strings.Contains(playSummary(days), "10 plays") { @@ -444,20 +998,63 @@ func TestProductCancelAfterACommittedMutationReconcilesFiles(t *testing.T) { } } -func TestProductRemoteContinuePlayingDoesNotRequireAnInstall(t *testing.T) { - m, f := newProductFixture(t) - f.snapshot.Activity = []registry.Activity{{ID: "acme/remote", PersonalBest: 42, LastPlayed: time.Now().UTC().Format(time.RFC3339)}} +func assertNavLibraryDivider(t *testing.T, nav string) { + t.Helper() + lines := strings.Split(ansi.Strip(nav), "\n") + for i, line := range lines { + if !strings.Contains(line, "Library") || strings.Contains(line, "login") || strings.Contains(line, "No games") { + continue + } + if i == 0 || strings.Trim(strings.TrimSpace(lines[i-1]), "─") != "" { + t.Fatalf("Library is not preceded by a divider:\n%s", nav) + } + return + } +} + +func TestProductNavListsLibraryGamesOnce(t *testing.T) { + m, _ := newProductFixture(t) m = drain(t, m, m.Init()) - items := m.productRecentItems() - if len(items) != 1 || items[0].ID != "acme/remote" || items[0].Kind != "play" || !strings.Contains(items[0].Detail, "best 42") { - t.Fatal("remote recent game missing") + seen := map[string]int{} + pages := 0 + for _, item := range m.productNavItems() { + if item.Kind == "page" && item.ID == "library" { + t.Fatal("library is still a navigation page") + } + if item.Kind == "game" { + seen[item.ID]++ + } + if item.Label == "Library" { + pages++ + } } - if m.localIndex("acme/remote") >= 0 { - t.Fatal("reading recent games installed one") + if pages != 1 || seen["acme/remote"] != 1 || seen["acme/local"] != 1 { + t.Fatalf("nav library games = %v heading=%d", seen, pages) } +} + +func TestProductNavLibraryEmptyStates(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m.games = nil + m.app.snapshot.Library = nil m.app.snapshot.SignedIn = false - if len(m.productRecentItems()) != 0 { - t.Fatal("signed-out view retained account-only history") + labels := func() []string { + var out []string + for _, item := range m.productNavItems() { + out = append(out, item.Label) + } + return out + } + got := strings.Join(labels(), "\n") + if !strings.Contains(got, "login to access your library") || strings.Contains(got, "No games added") { + t.Fatalf("signed-out empty library:\n%s", got) + } + m.app.snapshot.SignedIn = true + m.app.snapshot.LibraryKnown = true + got = strings.Join(labels(), "\n") + if !strings.Contains(got, "No games added") || strings.Contains(got, "login to access your library") { + t.Fatalf("signed-in empty library:\n%s", got) } } @@ -481,6 +1078,7 @@ func TestProductSidebarBreakpointAndContentCap(t *testing.T) { if strings.Contains(first, "│") { t.Fatal("sidebar appeared below breakpoint") } + m, _ = step(t, m, productKey("ctrl+n")) for _, width := range []int{120, 200} { m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 30}) first = strings.Split(ansi.Strip(view(m)), "\n")[0] @@ -496,31 +1094,142 @@ func TestProductSidebarBreakpointAndContentCap(t *testing.T) { m, _ = step(t, m, tea.WindowSizeMsg{Width: 80, Height: 24}) m.app.focus = 0 first = strings.Split(ansi.Strip(view(m)), "\n")[0] - if !strings.HasPrefix(first, "NAVIGATION") || []rune(first)[21] != '│' { + if !strings.HasPrefix(first, "TERMCADE") || []rune(first)[21] != '│' { t.Fatal("compact navigation is not an overlay") } } -func TestProductPausePixelSelectionAppliesOnRestart(t *testing.T) { +func TestProductNavigationLeavesMainFooterUncovered(t *testing.T) { + for _, width := range []int{80, 120} { + t.Run(fmt.Sprintf("width-%d", width), func(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 24}) + closed := strings.Split(ansi.Strip(view(m)), "\n") + cut := productFooterRow(closed) + if cut >= len(closed) || strings.TrimSpace(closed[cut]) != strings.Repeat("─", width) || !strings.Contains(strings.Join(closed[cut:], "\n"), "↻ R Refresh") { + t.Fatal("control: closed view missing main footer") + } + m, _ = step(t, m, productKey("ctrl+n")) + opened := strings.Split(ansi.Strip(view(m)), "\n") + openCut := productFooterRow(opened) + if openCut >= len(opened) || !strings.Contains(strings.Join(opened[openCut:], "\n"), "↻ R Refresh") { + t.Fatal("open navigation missing the shared footer") + } + for i := openCut; i < len(opened); i++ { + if strings.Contains(opened[i], "TERMCADE") || (len([]rune(opened[i])) > sidebarWidth-1 && []rune(opened[i])[sidebarWidth-1] == '│' && strings.Count(opened[i], "─") >= 8) { + t.Fatalf("navigation covered main footer row %d: %q", i, opened[i]) + } + } + }) + } +} + +func TestProductNavigationOverlaysSmallAndPushesLargeOnlyWhenOpen(t *testing.T) { + for _, width := range []int{80, 119, 120, 200} { + t.Run(fmt.Sprintf("width-%d", width), func(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 24}) + closed := ansi.Strip(view(m)) + closedLines := strings.Split(closed, "\n") + cut := productFooterRow(closedLines) + if cut >= len(closedLines) || strings.TrimSpace(closedLines[cut]) != strings.Repeat("─", width) { + t.Fatal("closed footer divider is not full terminal width") + } + if width < sidebarBreakpoint && strings.Contains(closedLines[0], "│") { + t.Fatal("closed navigation still reserves content width") + } + m, _ = step(t, m, productKey("ctrl+n")) + opened := strings.Split(ansi.Strip(view(m)), "\n") + if width < sidebarBreakpoint { + background := strings.Split(closed, "\n") + limit := min(productFooterRow(opened), productFooterRow(background)) + for i := 0; i < limit; i++ { + if ansi.Cut(opened[i], sidebarWidth, width) != ansi.Cut(background[i], sidebarWidth, width) { + t.Fatalf("overlay reflowed content on row %d", i) + } + } + if !strings.HasPrefix(opened[0], "TERMCADE") { + t.Fatal("small navigation is not an overlay") + } + } else { + if []rune(opened[0])[sidebarWidth-1] != '│' { + t.Fatal("large navigation did not reserve sidebar width") + } + at := strings.Index(opened[0], "MARKETPLACE") + wantColumn := sidebarWidth + (width-sidebarWidth-min(width-sidebarWidth, productContentMax))/2 + if at < 0 || lipgloss.Width(opened[0][:at]) != wantColumn { + t.Fatal("large navigation did not push the content") + } + } + m, _ = step(t, m, productKey("ctrl+n")) + if ansi.Strip(view(m)) != closed { + t.Fatal("closing navigation did not restore the full content area") + } + }) + } +} + +type wideNavigationGame struct{ fakeGame } + +func (g *wideNavigationGame) Info() sdk.Info { + return sdk.Info{ID: "acme/wide", Title: "WIDE", PixelW: 110, PixelH: 8} +} + +func TestProductOpenNavigationAdaptsDuringResizeWithoutSqueezingGames(t *testing.T) { + m, _ := newProductFixture(t) + m = drain(t, m, m.Init()) + g := &wideNavigationGame{} + m.games = []engine.Registration{{Info: g.Info(), New: func(sdk.CellShape) (sdk.Game, error) { return g, nil }}} + m, _ = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 24}) + m, _ = m.activateProduct("play", g.Info().ID) + m, _ = step(t, m, productKey("esc")) + frame := m.frame + m, _ = step(t, m, productKey("ctrl+n")) + for _, width := range []int{120, 200, 120} { + m, _ = step(t, m, tea.WindowSizeMsg{Width: width, Height: 24}) + rendered := ansi.Strip(view(m)) + if !m.app.gameNav || m.frame != frame || strings.Contains(rendered, "Terminal too small") { + t.Fatal("resize lost navigation or squeezed the game") + } + pushed := false + for _, line := range strings.Split(rendered, "\n") { + if strings.Contains(ansi.Cut(line, sidebarWidth, width), "SCORE") { + pushed = true + break + } + } + wantPush := width-sidebarWidth >= g.Info().PixelW+2 + if pushed != wantPush { + t.Fatalf("wrong navigation mode at width %d", width) + } + } + m = m.closeProductGame() +} + +func TestProductPauseDialogResumesAndExits(t *testing.T) { m, _ := newProductFixture(t) m = drain(t, m, m.Init()) m, _ = m.activateProduct("play", "acme/local") m, _ = step(t, m, productKey("esc")) - m, _ = step(t, m, productKey("down")) - m, _ = step(t, m, productKey("down")) - m, cmd := step(t, m, productKey("enter")) - if cmd != nil { - cmd() + if m.screen != screenPaused { + t.Fatal("esc did not pause") } - if m.shape.Name != sdk.Sextant.Name || m.canvas.Shape().Name != sdk.Quadrant.Name { - t.Fatal("pixel selector changed an active framebuffer") + if !strings.Contains(ansi.Strip(view(m)), "Paused") || !strings.Contains(ansi.Strip(view(m)), "[Resume]") { + t.Fatal("pause dialog missing") } - m, _ = step(t, m, productKey("up")) m, _ = step(t, m, productKey("enter")) - if m.screen != screenPlaying || m.canvas.Shape().Name != sdk.Sextant.Name { - t.Fatal("restart did not apply pixel selection") + if m.screen != screenPlaying { + t.Fatal("resume did not continue the game") + } + m, _ = step(t, m, productKey("esc")) + m, _ = step(t, m, productKey("tab")) + m, cmd := step(t, m, productKey("enter")) + m = drain(t, m, cmd) + if m.screen != screenProduct || m.app.loc.Page != "marketplace" { + t.Fatal("exit did not leave the game") } - m = m.closeProductGame() } func TestProductFormsConsumeTypingAndRequireConfirmation(t *testing.T) { @@ -570,6 +1279,30 @@ func TestProductLayoutsRemainBoundedAndResizeKeepsFocus(t *testing.T) { } } +func TestProductGameRowsShowIDDescriptionAndLikes(t *testing.T) { + m, f := newProductFixture(t) + f.snapshot.Catalog[0].Description = "A local toy" + f.snapshot.Catalog[0].Likes = 4 + m = drain(t, m, m.Init()) + item := m.productItems()[1] + if item.Label != "acme/local" || item.Detail != "A local toy" || item.Meta != "♡ 4" { + t.Fatalf("game row = %+v", item) + } + view := ansi.Strip(m.productPageView(80, 24)) + if !strings.Contains(view, "acme/local") || !strings.Contains(view, "♡ 4") || !strings.Contains(view, "A local toy") { + t.Fatalf("marketplace row missing id, likes, or description:\n%s", view) + } + m.app.focus, m.app.selection = 1, 1 + m, cmd := step(t, m, productKey("l")) + m = drain(t, m, cmd) + if !m.metadata("acme/local").Liked || m.metadata("acme/local").Likes != 5 { + t.Fatalf("L did not like: %+v", m.metadata("acme/local")) + } + if m.productItems()[1].Meta != "♥ 5" { + t.Fatalf("liked meta = %q", m.productItems()[1].Meta) + } +} + func TestProductFailedRequestsRemainReadableAndCancelable(t *testing.T) { m, f := newProductFixture(t) m = drain(t, m, m.Init()) diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 2af2de8..515dace 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -322,7 +322,7 @@ func (m Model) updateKey(key string) (tea.Model, tea.Cmd) { func (m Model) quitToMenu() Model { if m.productEnabled() { m = m.closeProductGame() - m.app.loc = productLocation{Page: "library"} + m.app.loc = productLocation{Page: "marketplace"} m.app.focus = 1 m.app.gameNav = false return m diff --git a/internal/shell/shell_test.go b/internal/shell/shell_test.go index 337ada6..fbe719a 100644 --- a/internal/shell/shell_test.go +++ b/internal/shell/shell_test.go @@ -407,6 +407,9 @@ func drain(t *testing.T, m Model, cmd tea.Cmd) Model { var mm tea.Model mm, cmd = m.Update(msg) m = mm.(Model) + if _, ok := msg.(tickMsg); ok { + break + } } return m } diff --git a/product_service.go b/product_service.go index eecc901..ed25cc4 100644 --- a/product_service.go +++ b/product_service.go @@ -61,19 +61,21 @@ func (p *productBackend) snapshot(ctx context.Context) shell.ProductSnapshot { out.AccountError = p.configErr.Error() return out } - // Catalog is public: never attach account credentials to public reads. - catalog, err := registry.New(p.store.Registry, "").WithContext(ctx).Catalog() - if err != nil { - out.CatalogError = err.Error() - } else { - out.Catalog = catalog - } c, s, err := p.client(ctx, false) if err != nil { out.AccountError = err.Error() - return out } - if s == nil { + catalogClient := registry.New(p.store.Registry, "").WithContext(ctx) + if err == nil && s != nil { + catalogClient = c + } + catalog, catalogErr := catalogClient.SearchCatalog("") + if catalogErr != nil { + out.CatalogError = catalogErr.Error() + } else { + out.Catalog = catalog + } + if err != nil || s == nil { return out } out.SignedIn = true @@ -106,6 +108,19 @@ func gameParts(id string) (string, string, error) { } func (p *productBackend) request(ctx context.Context, req shell.ProductRequest) (shell.ProductReply, error) { + // Search is a public, independent read: typing must not wait on or cancel an + // account mutation, and must never attach account credentials. + if req.Kind == "catalog-search" { + if p.configErr != nil { + return shell.ProductReply{}, p.configErr + } + c, _, err := p.client(ctx, false) + if err != nil { + c = registry.New(p.store.Registry, "").WithContext(ctx) + } + games, err := c.SearchCatalog(req.Value) + return shell.ProductReply{Catalog: games}, err + } // Pairing waits without holding the session lock. It returns credentials to // the model; only an accepted current-generation result may request saving. if req.Kind == "pair-start" || req.Kind == "pair-wait" { @@ -199,7 +214,7 @@ func (p *productBackend) request(ctx context.Context, req shell.ProductRequest) return out, e } switch req.Kind { - case "add", "remove", "install": + case "add", "remove", "install", "like", "unlike": a, b, e := gameParts(req.ID) if e != nil { return out, e @@ -212,6 +227,10 @@ func (p *productBackend) request(ctx context.Context, req shell.ProductRequest) if errors.Is(err, registry.ErrNotFound) { err = nil } + case "like": + _, err = c.Like(a, b) + case "unlike": + _, err = c.Unlike(a, b) case "install": // Membership is independent of installation. Play may install only // an account game; local-only play never calls this path. diff --git a/product_service_test.go b/product_service_test.go index 90b209d..0361594 100644 --- a/product_service_test.go +++ b/product_service_test.go @@ -41,9 +41,6 @@ func productBackendFixture(t *testing.T) (*productBackend, *productAPIFixture, s g := registry.Game{ID: "acme/game", Name: "Game", Version: "1.0.0", ABI: 1, HasPackage: true} switch r.URL.Path { case "/v1/games": - if r.Header.Get("Authorization") != "" { - t.Error("catalog received credentials") - } if r.URL.Query().Get("abi") != "" { t.Error("web-aligned catalog was filtered") } @@ -58,6 +55,9 @@ func productBackendFixture(t *testing.T) (*productBackend, *productAPIFixture, s json.NewEncoder(w).Encode(games) case "/v1/activity": json.NewEncoder(w).Encode([]registry.Activity{}) + case "/v1/games/acme/game/like": + liked := r.Method == http.MethodPut + json.NewEncoder(w).Encode(registry.LikeState{Likes: 1, Liked: liked}) case "/v1/library/acme/game": if r.Method == http.MethodPut { f.member = true