Help us shape the future of Go! Take the 2026 Go Developer Survey

app

package
v0.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 32 Imported by: 0

Documentation

Overview

Package app is the interactive shell: the screens a player moves between and the state they share. The view layer in internal/ui draws; this package decides what is drawn and what the keys do.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Back

func Back() tea.Cmd

Back leaves the current screen and returns to the previous one.

func Done

func Done(msg DoneMsg) tea.Cmd

Done is a command that emits a DoneMsg, which is what a screen returns from Update when it is finished.

func Fail

func Fail(err error) tea.Cmd

Fail leaves the current screen and reports why.

func OnboardingSeen added in v0.2.0

func OnboardingSeen(d Deps, player string) bool

OnboardingSeen reports whether the player at this keyboard has been through the introduction.

It answers for the profile that is playing, because being introduced to the game is something that happened to a person and not to a machine: two people sharing a machine with a profile each are two players, and the second of them is exactly the newcomer the introduction was written for. internal/profile holds the flag and the argument for it.

Where there is no profile to ask about — no store, or nobody chosen yet — it answers false, so the introduction is offered rather than silently skipped. That is the safer of the two wrong answers: offering it again costs one keypress, and never offering it loses the feature to whoever needed it most. In practice the question does not arise, because the launch path asks who is playing before it shows anything a player can reach this from.

func Open

func Open(s Screen) tea.Cmd

Open shows another screen without giving up this one.

This is the difference between a menu that launches a game and a menu that is consumed by launching one. With Replace, leaving the game emptied the stack and ended the program, so there was no way back to the menu the player had started from.

func Quit

func Quit() tea.Cmd

Quit ends the program from anywhere.

func Replace

func Replace(s Screen) tea.Cmd

Replace swaps the current screen for another. The screen asking is finished and will not be returned to.

Types

type Departing

type Departing interface {
	// Depart is called once, on the way out, before the program ends. It must
	// not block for long and must not expect to draw again.
	Depart()
}

Departing is implemented by a screen that has something to finish before the program ends, such as saving a game that is still in progress.

The shell answers the global quit key itself so that a busy screen cannot trap the player, but that means the key never reaches the screen. Without this, a screen's own handling of it is dead code: quitting with the plain letter saved an unfinished game while quitting with the control key silently discarded it, which is the same act from the player's point of view.

type Deps

type Deps struct {
	// ConfigDir is where profiles, games and settings live.
	ConfigDir string

	Profiles *profile.Store
	Board    *leaderboard.Board
	Games    *gamestore.Store
	// Study keeps the notes and bookmarks a player writes on the entries of a
	// replayed game, in a file beside the game rather than in its record. Nil
	// means notes are not kept: the replay still opens, and says so when it
	// is asked for one.
	Study *study.Store

	Theme  theme.Theme
	Styles *ui.Styles
	Keymap ui.Keymap

	// Now is the clock, injected so that a test can pin the durations that end
	// up on the leaderboard.
	Now func() time.Time

	// Note lets a screen leave a line for the player to read after the
	// interface has closed. Anything printed while the alternate screen is up
	// disappears with it, so a screen that saved a game on the way out has
	// nowhere to say so: the player was left not knowing whether their game
	// survived. Nil means nobody is listening.
	Note func(string)
}

Deps are the collaborators every screen needs. It is passed by value; the stores inside it are shared and safe for concurrent use.

func (Deps) Clock

func (d Deps) Clock() time.Time

Clock returns the time, defaulting to the real one.

type DoneMsg

type DoneMsg struct {
	Next Screen
	Err  error
	Quit bool
}

DoneMsg asks the shell to leave the screen that sent it.

A screen that wants to be replaced sets Next. A screen that wants to go back to where it came from leaves Next nil. A screen that failed sets Err, which the shell shows before going back. Quit ends the program.

type GameConfig

type GameConfig struct {
	Kind  gamestore.Kind
	Rules game.Ruleset

	// Seats says who plays each side. Both sides must be filled.
	Seats map[game.Player]Seat

	// Hints allows the player to ask for advice, which only makes sense when
	// there is an engine to ask.
	Hints bool
	// HintFor is the engine consulted for hints, which is the opponent bot in a
	// game against one and may be a separate engine otherwise.
	HintFor bot.Bot

	// Session is the network connection for a live remote game, nil otherwise.
	Session netplay.Session

	// Codes drives a remote seat by move codes the players exchange by hand,
	// with no connection at all. It is mutually exclusive with Session: a
	// correspondence game has a remote seat and no session, which is the one
	// case where that combination is legitimate.
	Codes bool

	// Resume, when set, continues a stored game instead of starting a new one.
	Resume *gamestore.Saved
	// StoreID is the identifier the game is saved under, and for a
	// correspondence game it is also the identifier its move codes are bound
	// to, so a code from another game is refused. Empty means allocate one.
	StoreID string
}

GameConfig describes a game to be played. The command line builds one of these and hands it to NewGameScreen.

func RemoteConfig added in v0.3.0

func RemoteConfig(player string, session netplay.Session) GameConfig

RemoteConfig is the game a freshly connected session plays: the terms are whatever the handshake settled on, and there is no stored game behind it yet.

It is here rather than in each of the two callers because the command line and the menu open the same game once a connection exists, and a seat map built twice is a seat map that can be built two ways.

func (GameConfig) LocalSide

func (c GameConfig) LocalSide() (game.Player, bool)

LocalSide returns the side the player at this keyboard is on, and whether exactly one side is local. In a hotseat game both sides are local, so this reports false.

func (GameConfig) Opponent

func (c GameConfig) Opponent(local game.Player) string

Opponent returns the leaderboard name for the side opposing the local player.

type LeaderboardScreen added in v0.6.0

type LeaderboardScreen struct {
	// contains filtered or unexported fields
}

LeaderboardScreen is the standings, one step inside them a participant's own games, and one step inside those the game itself.

It is a screen rather than a panel on the menu because it is a place the player moves about in and leaves in stages. Choosing a player has to come back to the standings, and watching a game has to come back to the list it was chosen from; the shell's stack gives the last of those — the replay is opened on top and returns here — and the two lists inside this screen keep their own position, so every step out lands on what the step in was taken from.

Nothing here reads a store while drawing. The standings are read when the screen is made, a participant's games when that participant is chosen, and the saved game behind a row when that row is opened; a frame is built per keypress, and a listing that went to disk for every one of them would make moving the highlight cost a directory read.

func (*LeaderboardScreen) Init added in v0.6.0

func (s *LeaderboardScreen) Init() tea.Cmd

Init implements tea.Model.

func (*LeaderboardScreen) Update added in v0.6.0

func (s *LeaderboardScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (*LeaderboardScreen) View added in v0.6.0

func (s *LeaderboardScreen) View() tea.View

View implements tea.Model. The shell owns the alternate screen, so only the content is set.

type Menu struct {
	// contains filtered or unexported fields
}

Menu is the main menu: everything a player can reach, each entry with a one-line explanation shown while it is highlighted, plus the small forms that collect the choices a game needs before it can start.

The forms live inside this model rather than being screens of their own because a screen cannot push another screen: the shell's DoneMsg replaces or pops, so a wizard built out of screens could not walk backwards.

func NewMenu

func NewMenu(d Deps, player string) *Menu

NewMenu returns the main menu for a player.

func (m *Menu) Init() tea.Cmd

Init implements tea.Model. On the first run — of this profile, on this machine — the introduction opens on top of the menu, so that finishing or skipping it lands the player here, where everything else is. The check belongs in Init and not in revealed(): Init runs once, when the menu is built, so a player coming back from a game cannot be handed the tour a second time.

func (m *Menu) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (m *Menu) View() tea.View

View implements tea.Model. The shell owns the alternate screen, so only the content is set.

type Noting added in v0.1.1

type Noting interface {
	// DepartNote returns the line and forgets it, so that one departure is
	// announced exactly once.
	DepartNote() string
}

Noting is implemented by a screen that has a line for the player on its way out, such as which game it has just saved and how to open it again.

The screen does not print the line itself because it cannot know where the player will read it. A departure that ends the program has to leave it for the command line, since the interface takes its own output with it; a departure that goes back to the menu has a screen to say it on, and saying it there, at the time, is the whole point of saying it. The shell is what knows the difference, so it collects the line and places it.

type OpenMsg

type OpenMsg struct {
	Screen Screen
}

OpenMsg asks the shell to show another screen on top of the one that sent it, which stays underneath and is returned to when the new one is finished.

type Picker

type Picker struct {
	// contains filtered or unexported fields
}

Picker asks who is playing, which is the first thing the program does (R13). It satisfies R14 two ways at once: the list is browsable with an empty query, most recently played first, for someone who cannot recall how they spelled their name, and typing filters it fuzzily for someone who can nearly recall it. A name that is not in the list is offered for creation, which is also the whole of the first-run path (R12).

func NewPicker

func NewPicker(d Deps, prompt string) *Picker

NewPicker returns the profile chooser. The prompt is the question at the top of the screen; an empty one falls back to the launch wording.

Once a name is chosen the picker replaces itself with the main menu, which is what launching the program does. Chosen overrides that for a caller that wants the name for something else.

func (*Picker) Cancelled

func (p *Picker) Cancelled(f func() tea.Cmd) *Picker

Cancelled sets what escape does. The picker has no answer of its own: at launch it is the first screen, and a program that ends on one stray escape is worse than one that ignores the key. Setting this is therefore what puts "esc back" on the status line as well, so the offer and the key arrive together.

func (*Picker) Chosen

func (p *Picker) Chosen(f func(name string) tea.Cmd) *Picker

Chosen replaces what happens once a name has been picked. It is how the picker is embedded in another screen: the command can emit a message that screen handles instead of leaving the picker.

The default builds a fresh menu and replaces the picker with it, which is right when the picker is the program's first screen and there is nothing behind it. A caller that opened the picker on top of its own screen must set this, or the replacement leaves a second menu stacked on the first and the screen the player came from is two deep and never returned to. The choice is persisted before this runs, so a handler only has to decide where the player goes next.

func (*Picker) Init

func (p *Picker) Init() tea.Cmd

Init implements tea.Model.

func (*Picker) Update

func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model.

func (*Picker) View

func (p *Picker) View() tea.View

View implements tea.Model. The shell owns the alternate screen, so only the content is set here.

type RemoteResume added in v0.3.0

type RemoteResume struct {
	// Saved is the stored row, which the continued game is saved back into.
	Saved gamestore.Saved
	// Snapshot is what the two ends reconcile their transcripts against. Its
	// Role is not meaningful here — Host and Guest below each set their own,
	// because the role is the answer to how the connection is being made and
	// not a property of the stored game.
	Snapshot netplay.Snapshot
}

RemoteResume is a stored network game prepared to be continued on a new connection.

What a lost connection leaves behind is a saved game like any other: the record, the seats, the identifier. It does not say which end of the connection this machine was, and it should not — the protocol's roles belong to a connection, and the players are free to swap who waits when they make the next one. So the role is asked for at the point the connection is made, through Host or Guest below, and everything else comes from the stored row.

The identity of the game is what this type carries: continuing one goes back into the row it came out of, under the same identifier, with the same seats and the same transcript. Nothing here starts a game beside the saved one.

func PrepareRemoteResume added in v0.3.0

func PrepareRemoteResume(sv gamestore.Saved, player string) (RemoteResume, error)

PrepareRemoteResume checks that a stored game is a network game that can be continued and reads the transcript both ends will compare out of it.

It refuses rather than falling back: a row of another kind, a finished game, a game belonging to another profile or one whose record will not replay has no connection to get back, and quietly starting a fresh game instead would lose the one the player asked for.

func (RemoteResume) Continue added in v0.3.0

func (r RemoteResume) Continue(session netplay.Session) GameConfig

Continue is the game the reconnected session plays: the stored row, the stored seats, the stored identifier.

func (RemoteResume) Describe added in v0.3.0

func (r RemoteResume) Describe() string

Describe is the one-line summary of what is being continued, shown before the wait for the connection begins so that a reconnection cannot be mistaken for a new game.

func (RemoteResume) Guest added in v0.3.0

func (r RemoteResume) Guest() netplay.GuestOptions

Guest is how to continue it by connecting to an opponent who is waiting.

The ruleset and the side are both stated rather than left for the host to decide, which is what makes a host offering something else a refusal instead of a game played on terms the saved one was not.

func (RemoteResume) Host added in v0.3.0

func (r RemoteResume) Host() netplay.HostOptions

Host is how to continue the game by waiting for the opponent to arrive.

type ReplayScreen

type ReplayScreen struct {
	// contains filtered or unexported fields
}

ReplayScreen walks through a finished game one move at a time.

The record is replayed in full when the screen opens, because replaying forwards is the same path a saved game takes when it is loaded: a record that cannot be replayed is refused here instead of appearing to work until the player steps into the part of it that does not. What that replay leaves behind is one position and the entries that lead to it, not a position per entry — see recordCursor.

func (*ReplayScreen) Depart added in v0.8.0

func (s *ReplayScreen) Depart()

Depart stops an analysis still running when the program ends with the review open: the engine would be working for a screen that is no longer there.

func (*ReplayScreen) Init

func (s *ReplayScreen) Init() tea.Cmd

Init satisfies tea.Model.

func (*ReplayScreen) Update

func (s *ReplayScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update satisfies tea.Model.

func (*ReplayScreen) View

func (s *ReplayScreen) View() tea.View

View satisfies tea.Model.

type Screen

type Screen interface {
	tea.Model
}

Screen is one place the player can be. Screens are Bubble Tea models; they hand control back by emitting a DoneMsg rather than by returning tea.Quit, so that the shell decides what happens next.

func NewGameScreen

func NewGameScreen(d Deps, cfg GameConfig) (Screen, error)

NewGameScreen builds the screen for one game. Both sides must have a seat and at least one of them must be played at this keyboard. A remote seat is driven either by a live session or, in a correspondence game, by the move codes the players exchange by hand; it needs exactly one of the two.

func NewLeaderboardScreen added in v0.6.0

func NewLeaderboardScreen(d Deps) (Screen, error)

NewLeaderboardScreen reads the standings and prepares the screen.

func NewOnboarding added in v0.2.0

func NewOnboarding(d Deps, player string) (Screen, error)

NewOnboarding builds the introduction. It is a Screen like any other: the caller decides whether to show it, and the shell decides where leaving it goes. player is the profile the run is playing as. It is passed in rather than read from the store because the two can differ for the length of one command.

func NewReplayScreen

func NewReplayScreen(d Deps, saved gamestore.Saved) (Screen, error)

NewReplayScreen prepares a stored game for review. The record is decoded, replayed and checked here, so a record that has been altered is refused before the screen exists rather than at the step that reaches the damage.

func NewTutorialScreen

func NewTutorialScreen(d Deps, lessonID string) (Screen, error)

NewTutorialScreen opens the interactive tutorial. An empty lessonID starts at the lesson chooser; a lesson id starts that lesson, and leaving it then leaves the screen rather than dropping into a chooser the player never saw.

type Seat

type Seat struct {
	// Profile is the local player's name, empty for a bot or a remote opponent.
	Profile string
	// Bot is the opponent engine, nil unless this seat is a bot.
	Bot bot.Bot
	// Remote marks a seat driven by a networked opponent.
	Remote bool
	// Label is what the interface calls this seat.
	Label string
}

Seat says who is playing one side of a game.

func (Seat) Human

func (s Seat) Human() bool

Human reports whether this seat is played at this keyboard.

type Shell

type Shell struct {
	// contains filtered or unexported fields
}

Shell is the root Bubble Tea model: it owns the stack of screens, the terminal size, the alternate screen and the error banner, and it is the only thing in the program that calls tea.Quit.

Screens hand control back with a DoneMsg instead of quitting or pushing themselves, so the rule for what happens when a screen finishes lives in one place and a screen can be reached from more than one place without knowing where it will return to.

func NewShell

func NewShell(d Deps, first Screen) *Shell

NewShell returns the root model showing first.

func (*Shell) Init

func (s *Shell) Init() tea.Cmd

Init implements tea.Model.

func (*Shell) Push

func (s *Shell) Push(sc Screen) tea.Cmd

Push shows sc on top of the current screen, which stays on the stack and is revealed again when sc finishes.

func (*Shell) Update

func (s *Shell) Update(msg tea.Msg) (tea.Model, tea.Cmd)

Update implements tea.Model. Everything except the shell's own messages and the global quit key goes to the screen on top.

func (*Shell) View

func (s *Shell) View() tea.View

View implements tea.Model. The shell owns the alternate screen, so no screen has to; only a screen's content is used.

type ThemeChangedMsg

type ThemeChangedMsg struct {
	Theme  theme.Theme
	Styles *ui.Styles
}

ThemeChangedMsg announces that the player chose a different colour scheme. The shell follows it for its own drawing and passes it on, so a screen holding its own copy of Deps can follow too.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL