Skip to content

Commit

Permalink
refactor(ui): Use Render method, drop constructor parameters (#111)
Browse files Browse the repository at this point in the history
For all UI components defined in the UI package,
make the following changes:

- Instead of a `View() string` method, use a `Render(w io.Writer)`
method.
  This allows reusing the same `strings.Builder` for a render operation,
  reducing unnecessary string concatenation.
- Drop constructor parameters from all `New*` functions.
  The value pointers are placed with an optional `WithValue` method,
  and in case of Select, the list of options with `WithOptions`.
  • Loading branch information
abhinav authored May 27, 2024
1 parent 80674d2 commit a4bc5da
Show file tree
Hide file tree
Showing 14 changed files with 184 additions and 87 deletions.
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ linters-settings:
- fmt.Fprintf
- fmt.Fprintln

# This is always a strings.Builder, and can't fail.
- (go.abhg.dev/gs/internal/ui.Writer).WriteString
- (go.abhg.dev/gs/internal/ui.Writer).Write

govet:
enable:
- niliness
Expand Down
6 changes: 3 additions & 3 deletions branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,13 @@ nextBranch:
return "", errors.New("no branches available")
}

var result string
prompt := ui.NewSelect(&result, branches...).
prompt := ui.NewSelect().
WithOptions(branches...).
WithTitle(p.Title).
WithDescription(p.Description)
if err := ui.Run(prompt); err != nil {
return "", fmt.Errorf("select branch: %w", err)
}

return result, nil
return prompt.Value(), nil
}
3 changes: 2 additions & 1 deletion branch_checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ func (cmd *branchCheckoutCmd) Run(ctx context.Context, log *log.Logger, opts *gl
} else {
log.Warnf("%v: branch not tracked", cmd.Name)
track := true
prompt := ui.NewConfirm(&track).
prompt := ui.NewConfirm().
WithValue(&track).
WithTitle("Do you want to track this branch now?")
if err := ui.Run(prompt); err != nil {
return fmt.Errorf("prompt: %w", err)
Expand Down
3 changes: 2 additions & 1 deletion branch_rename.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ func (cmd *branchRenameCmd) Run(ctx context.Context, log *log.Logger, opts *glob
}

if cmd.Name == "" {
prompt := ui.NewInput(&cmd.Name).
prompt := ui.NewInput().
WithValue(&cmd.Name).
WithTitle("New branch name").
WithDescription(fmt.Sprintf("Renaming branch: %v", oldName)).
WithValidate(func(s string) error {
Expand Down
9 changes: 6 additions & 3 deletions branch_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ func (cmd *branchSubmitCmd) Run(
var fields []ui.Field
if cmd.Title == "" {
cmd.Title = defaultTitle
title := ui.NewInput(&cmd.Title).
title := ui.NewInput().
WithValue(&cmd.Title).
WithTitle("Title").
WithDescription("Short summary of the pull request").
WithValidate(func(s string) error {
Expand All @@ -184,7 +185,8 @@ func (cmd *branchSubmitCmd) Run(

if cmd.Body == "" {
cmd.Body = defaultBody
body := ui.NewOpenEditor(&cmd.Body).
body := ui.NewOpenEditor().
WithValue(&cmd.Body).
WithTitle("Body").
WithDescription("Open your editor to write " +
"a detailed description of the pull request")
Expand All @@ -196,7 +198,8 @@ func (cmd *branchSubmitCmd) Run(

if opts.Prompt {
// TODO: default to true if subject is "WIP" or similar.
draft := ui.NewConfirm(&cmd.Draft).
draft := ui.NewConfirm().
WithValue(&cmd.Draft).
WithTitle("Draft").
WithDescription("Mark the pull request as a draft?")
fields = append(fields, draft)
Expand Down
47 changes: 27 additions & 20 deletions internal/ui/confirm.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package ui

import (
"strings"

"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
Expand Down Expand Up @@ -55,15 +53,13 @@ type Confirm struct {

var _ Field = (*Confirm)(nil)

// NewConfirm builds a new confirm field that writes its result
// to the given boolean pointer.
//
// The initial value of the boolean pointer will be used as the default.
func NewConfirm(value *bool) *Confirm {
// NewConfirm builds a new confirm field that prompts the user
// with a yes or no question.
func NewConfirm() *Confirm {
return &Confirm{
KeyMap: DefaultConfirmKeyMap,
Style: DefaultConfirmStyle,
value: value,
value: new(bool),
}
}

Expand All @@ -72,6 +68,19 @@ func (c *Confirm) Err() error {
return nil
}

// WithValue sets the destination for the confirm field.
// The result of the field will be written to the given boolean pointer.
// The pointer's current value will be used as the default.
func (c *Confirm) WithValue(value *bool) *Confirm {
c.value = value
return c
}

// Value returns the current value of the confirm field.
func (c *Confirm) Value() bool {
return *c.value
}

// WithTitle sets the title for the confirm field.
func (c *Confirm) WithTitle(title string) *Confirm {
c.title = title
Expand Down Expand Up @@ -116,19 +125,17 @@ func (c *Confirm) Update(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}

// View renders the confirm field.
func (c *Confirm) View() string {
var s strings.Builder
s.WriteString("[")
// Render renders the confirm field to the given writer.
func (c *Confirm) Render(w Writer) {
w.WriteString("[")
if *c.value {
s.WriteString(c.Style.DefaultValue.Render("Y"))
s.WriteString("/")
s.WriteString(c.Style.NonDefaultValue.Render("n"))
w.WriteString(c.Style.DefaultValue.Render("Y"))
w.WriteString("/")
w.WriteString(c.Style.NonDefaultValue.Render("n"))
} else {
s.WriteString(c.Style.NonDefaultValue.Render("y"))
s.WriteString("/")
s.WriteString(c.Style.DefaultValue.Render("N"))
w.WriteString(c.Style.NonDefaultValue.Render("y"))
w.WriteString("/")
w.WriteString(c.Style.DefaultValue.Render("N"))
}
s.WriteString("]")
return s.String()
w.WriteString("]")
}
38 changes: 38 additions & 0 deletions internal/ui/confirm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ui_test

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/stretchr/testify/assert"
"go.abhg.dev/gs/internal/ui"
)

func TestConfirm_accept(t *testing.T) {
t.Run("default/false", func(t *testing.T) {
c := ui.NewConfirm()
c.Update(tea.KeyMsg{Type: tea.KeyEnter})
assert.False(t, c.Value())
})

t.Run("default/true", func(t *testing.T) {
value := true
c := ui.NewConfirm().WithValue(&value)
c.Update(tea.KeyMsg{Type: tea.KeyEnter})

assert.True(t, c.Value())
assert.True(t, value)
})

t.Run("yes", func(t *testing.T) {
c := ui.NewConfirm()
c.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
assert.True(t, c.Value())
})

t.Run("no", func(t *testing.T) {
c := ui.NewConfirm()
c.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
assert.False(t, c.Value())
})
}
13 changes: 9 additions & 4 deletions internal/ui/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,16 @@ func AcceptField() tea.Msg {
return acceptFieldMsg{}
}

// Writer receives a rendered view of a [Field].
type Writer interface {
io.Writer
io.StringWriter
}

// Field is a single field in a form.
type Field interface {
Update(msg tea.Msg) tea.Cmd
View() string
// FIXME: Refactor to View(io.Writer) error
Render(Writer)

// Err reports any errors for the field at render time.
// These will be rendered in red below the field.
Expand Down Expand Up @@ -187,7 +192,7 @@ func (f *Form) View() string {
return s.String()
}

func (f *Form) renderField(w io.Writer, field Field, accepted bool) {
func (f *Form) renderField(w Writer, field Field, accepted bool) {
if title := field.Title(); title != "" {
titleStyle := f.Style.Title
if accepted {
Expand All @@ -196,7 +201,7 @@ func (f *Form) renderField(w io.Writer, field Field, accepted bool) {

fmt.Fprintf(w, "%s: ", titleStyle.Render(title))
}
fmt.Fprint(w, field.View())
field.Render(w)
if err := field.Err(); err != nil {
fmt.Fprintf(w, "\n%s", f.Style.Error.Render(err.Error()))
}
Expand Down
24 changes: 14 additions & 10 deletions internal/ui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,27 @@ type Input struct {

var _ Field = (*Input)(nil)

// NewInput builds a new input field that writes its result
// to the given string pointer.
//
// If the value is non-empty, it will be used as the initial value.
func NewInput(value *string) *Input {
// NewInput builds a new input field.
func NewInput() *Input {
m := textinput.New()
m.Prompt = "" // we have our own prompt
m.SetValue(*value)
m.Focus()
return &Input{
KeyMap: DefaultInputKeyMap,
Style: DefaultInputStyle,
model: &m,
value: value,
value: new(string),
}
}

// WithValue sets the destination for the input field.
// If the value is non-empty, it will be used as the initial value.
func (i *Input) WithValue(value *string) *Input {
i.value = value
i.model.SetValue(*value)
return i
}

// WithTitle sets the title of the input field.
func (i *Input) WithTitle(title string) *Input {
i.title = title
Expand Down Expand Up @@ -116,7 +120,7 @@ func (i *Input) Update(msg tea.Msg) tea.Cmd {
return tea.Batch(cmds...)
}

// View renders the input field.
func (i *Input) View() string {
return i.model.View()
// Render renders the input field.
func (i *Input) Render(w Writer) {
w.WriteString(i.model.View())
}
29 changes: 17 additions & 12 deletions internal/ui/editor_prompt.go → internal/ui/open_editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,15 @@ type OpenEditor struct {

var _ Field = (*OpenEditor)(nil)

// NewOpenEditor builds an [OpenEditor] field with default values.
// It will feed the value pointer with the content of the editor.
//
// If the value is non-empty, the editor will be pre-filled with its content.
func NewOpenEditor(value *string) *OpenEditor {
// NewOpenEditor builds an [OpenEditor] field.
// It will prompt the user to open an editor and write a message,
// or accept the current value.
func NewOpenEditor() *OpenEditor {
ed := &OpenEditor{
KeyMap: DefaultOpenEditorKeyMap,
Style: DefaultOpenEditorStyle,
Editor: DefaultEditor(),
value: value,
value: new(string),
}
if ed.Editor.Command == "" {
ed.err = errors.New("no editor found: please set $EDITOR")
Expand All @@ -107,6 +106,15 @@ func (a *OpenEditor) Err() error {
return a.err
}

// WithValue specifies the value to edit.
// The current value will be used as the initial content of the editor.
// The value will be updated when the editor is closed,
// or left unchanged if the user skips the editor.
func (a *OpenEditor) WithValue(value *string) *OpenEditor {
a.value = value
return a
}

// WithTitle sets the title for the field.
func (a *OpenEditor) WithTitle(title string) *OpenEditor {
a.title = title
Expand Down Expand Up @@ -191,14 +199,11 @@ func (a *OpenEditor) Update(msg tea.Msg) tea.Cmd {
return nil
}

// View renders the field to the screen.
func (a *OpenEditor) View() string {
var s strings.Builder
fmt.Fprintf(&s, "Press [%v] to open %v or [%v] to skip",
// Render renders the field to the screen.
func (a *OpenEditor) Render(w Writer) {
fmt.Fprintf(w, "Press [%v] to open %v or [%v] to skip",
a.Style.Key.Render(a.KeyMap.Edit.Help().Key),
a.Style.Editor.Render(a.Editor.Command),
a.Style.Key.Render(a.KeyMap.Accept.Help().Key),
)

return s.String()
}
Loading

0 comments on commit a4bc5da

Please sign in to comment.