Repository: charmbracelet/bubbletea
Pull request #734 (2023-05-05 19:24:45)

chore: make input options mutually exclusive
Just some internal housekeeping: basically `WithCustomInput` and `WithInputTTY` can not longer both exist in `startupOptions` internally. When both are passed as `ProgramOption`s the last one wins.

meowgorithm: Thanks @muesli!

Patch:
diff --git a/options.go b/options.go
index 7a63c67..30f7e6c 100644
--- a/options.go
+++ b/options.go
@@ -37,18 +37,20 @@ func WithOutput(output io.Writer) ProgramOption {
 }
 
 // WithInput sets the input which, by default, is stdin. In most cases you
-// won't need to use this.
+// won't need to use this. To disable input entirely pass nil.
+//
+//	p := NewProgram(model, WithInput(nil))
 func WithInput(input io.Reader) ProgramOption {
 	return func(p *Program) {
 		p.input = input
-		p.startupOptions |= withCustomInput
+		p.inputType = customInput
 	}
 }
 
 // WithInputTTY opens a new TTY for input (or console input device on Windows).
 func WithInputTTY() ProgramOption {
 	return func(p *Program) {
-		p.startupOptions |= withInputTTY
+		p.inputType = ttyInput
 	}
 }
 
diff --git a/tea.go b/tea.go
index e61bb5a..ca24eac 100644
--- a/tea.go
+++ b/tea.go
@@ -60,6 +60,24 @@ type Cmd func() Msg
 
 type handlers []chan struct{}
 
+type inputType int
+
+const (
+	defaultInput inputType = iota
+	ttyInput
+	customInput
+)
+
+// String implements the stringer interface for [inputType]. It is inteded to
+// be used in testing.
+func (i inputType) String() string {
+	return [...]string{
+		"default input",
+		"tty input",
+		"custom input",
+	}[i]
+}
+
 // Options to customize the program during its initialization. These are
 // generally set with ProgramOptions.
 //
@@ -74,8 +92,6 @@ const (
 	withAltScreen startupOptions = 1 << iota
 	withMouseCellMotion
 	withMouseAllMotion
-	withInputTTY
-	withCustomInput
 	withANSICompressor
 	withoutSignalHandler
 
@@ -94,6 +110,8 @@ type Program struct {
 	// treated as bits. These options can be set via various ProgramOptions.
 	startupOptions startupOptions
 
+	inputType inputType
+
 	ctx    context.Context
 	cancel context.CancelFunc
 
@@ -141,7 +159,6 @@ type QuitMsg struct{}
 func NewProgram(model Model, opts ...ProgramOption) *Program {
 	p := &Program{
 		initialModel: model,
-		input:        os.Stdin,
 		msgs:         make(chan Msg),
 	}
 
@@ -371,8 +388,11 @@ func (p *Program) Run() (Model, error) {
 
 	defer p.cancel()
 
-	switch {
-	case p.startupOptions.has(withInputTTY):
+	switch p.inputType {
+	case defaultInput:
+		p.input = os.Stdin
+
+	case ttyInput:
 		// Open a new TTY, by request
 		f, err := openInputTTY()
 		if err != nil {
@@ -381,7 +401,7 @@ func (p *Program) Run() (Model, error) {
 		defer f.Close() //nolint:errcheck
 		p.input = f
 
-	case !p.startupOptions.has(withCustomInput):
+	case customInput:
 		// If the user hasn't set a custom input, and input's not a terminal,
 		// open a TTY so we can capture input as normal. This will allow things
 		// to "just work" in cases where data was piped or redirected into this


Test patch:
diff --git a/options_test.go b/options_test.go
index a66483d..fdbc5b2 100644
--- a/options_test.go
+++ b/options_test.go
@@ -14,13 +14,13 @@ func TestOptions(t *testing.T) {
 		}
 	})
 
-	t.Run("input", func(t *testing.T) {
+	t.Run("custom input", func(t *testing.T) {
 		var b bytes.Buffer
 		p := NewProgram(nil, WithInput(&b))
 		if p.input != &b {
 			t.Errorf("expected input to custom, got %v", p.input)
 		}
-		if p.startupOptions&withCustomInput == 0 {
+		if p.inputType != customInput {
 			t.Errorf("expected startup options to have custom input set, got %v", p.input)
 		}
 	})
@@ -49,6 +49,25 @@ func TestOptions(t *testing.T) {
 		}
 	})
 
+	t.Run("input options", func(t *testing.T) {
+		exercise := func(t *testing.T, opt ProgramOption, expect inputType) {
+			p := NewProgram(nil, opt)
+			if p.inputType != expect {
+				t.Errorf("expected input type %s, got %s", expect, p.inputType)
+			}
+		}
+
+		t.Run("tty input", func(t *testing.T) {
+			exercise(t, WithInputTTY(), ttyInput)
+		})
+
+		t.Run("custom input", func(t *testing.T) {
+			var b bytes.Buffer
+			exercise(t, WithInput(&b), customInput)
+		})
+
+	})
+
 	t.Run("startup options", func(t *testing.T) {
 		exercise := func(t *testing.T, opt ProgramOption, expect startupOptions) {
 			p := NewProgram(nil, opt)
@@ -57,10 +76,6 @@ func TestOptions(t *testing.T) {
 			}
 		}
 
-		t.Run("input tty", func(t *testing.T) {
-			exercise(t, WithInputTTY(), withInputTTY)
-		})
-
 		t.Run("alt screen", func(t *testing.T) {
 			exercise(t, WithAltScreen(), withAltScreen)
 		})
@@ -100,10 +115,13 @@ func TestOptions(t *testing.T) {
 
 	t.Run("multiple", func(t *testing.T) {
 		p := NewProgram(nil, WithMouseAllMotion(), WithAltScreen(), WithInputTTY())
-		for _, opt := range []startupOptions{withMouseAllMotion, withAltScreen, withInputTTY} {
+		for _, opt := range []startupOptions{withMouseAllMotion, withAltScreen} {
 			if !p.startupOptions.has(opt) {
 				t.Errorf("expected startup options have %v, got %v", opt, p.startupOptions)
 			}
+			if p.inputType != ttyInput {
+				t.Errorf("expected input to be %v, got %v", opt, p.startupOptions)
+			}
 		}
 	})
 }