feat: naming dialog sub-model (state + keys)
c57f1fb956e39f1170685be14125ba62351fba6d
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-30 18:55
parent cda48477
feat: naming dialog sub-model (state + keys) Centered filename prompt as a self-contained Bubbletea sub-model: Open/Value/SetError plus Enter-confirms, Esc-cancels (TASK-046).
2 files changed
internal/dialog/dialog.go +84 −0
@@ -0,0 +1,84 @@
+// Package dialog is glint's centered naming prompt: the modal box that asks for
+// a filename when a note is created or renamed. It is a self-contained
+// sub-model — it knows nothing about files, directories, or the app's modes,
+// and only reports whether the user confirmed or cancelled.
+package dialog
+
+import (
+ "strings"
+
+ "glint/internal/theme"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Result is what a key press did to the dialog.
+type Result int
+
+const (
+ ResultNone Result = iota // still typing
+ ResultConfirm // Enter — the caller reads Value()
+ ResultCancel // Esc — the caller restores the previous mode
+)
+
+// Model is the naming prompt.
+type Model struct {
+ th theme.Theme
+ input textinput.Model
+ title string // "New note" / "Rename"
+ dirHint string // the folder the name resolves under, shown under the title
+ err string // validation message shown under the input
+ width int // content-column width the box is sized against
+}
+
+// New builds an empty dialog. Open puts it into a usable state.
+func New(th theme.Theme) *Model {
+ ti := textinput.New()
+ ti.Prompt = "› "
+ ti.Placeholder = "name…"
+ return &Model{th: th, input: ti}
+}
+
+// Open resets the dialog for one use: title above, dirHint under it, prefill in
+// the input with the cursor at the end, no error.
+func (m *Model) Open(title, dirHint, prefill string) {
+ m.title = title
+ m.dirHint = dirHint
+ m.err = ""
+ m.input.SetValue(prefill)
+ m.input.CursorEnd()
+ m.input.Focus()
+}
+
+// Title is the dialog's heading.
+func (m *Model) Title() string { return m.title }
+
+// Value is the typed name with surrounding whitespace trimmed.
+func (m *Model) Value() string { return strings.TrimSpace(m.input.Value()) }
+
+// SetError shows msg under the input; the dialog stays open. Typing clears it.
+func (m *Model) SetError(msg string) { m.err = msg }
+
+// SetTheme repaints the dialog after a theme cycle.
+func (m *Model) SetTheme(th theme.Theme) { m.th = th }
+
+// SetWidth sets the content-column width the box is sized against.
+func (m *Model) SetWidth(w int) { m.width = w }
+
+// Update handles one message. Enter confirms, Esc cancels, everything else
+// edits the input (and clears a stale validation message).
+func (m *Model) Update(msg tea.Msg) (Result, tea.Cmd) {
+ if k, ok := msg.(tea.KeyMsg); ok {
+ switch k.Type {
+ case tea.KeyEnter:
+ return ResultConfirm, nil
+ case tea.KeyEsc:
+ return ResultCancel, nil
+ }
+ }
+ m.err = ""
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ return ResultNone, cmd
+}
internal/dialog/dialog_test.go +97 −0
@@ -0,0 +1,97 @@
+package dialog
+
+import (
+ "testing"
+
+ "glint/internal/theme"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func newModel() *Model { return New(theme.FlexokiDark()) }
+
+func typeRunes(m *Model, s string) {
+ for _, r := range s {
+ m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+}
+
+func TestOpenSetsTitleAndPrefill(t *testing.T) {
+ m := newModel()
+ m.Open("Rename", "~/Notes/", "my-note")
+ if m.Title() != "Rename" {
+ t.Errorf("Title() = %q, want Rename", m.Title())
+ }
+ if m.Value() != "my-note" {
+ t.Errorf("Value() = %q, want my-note", m.Value())
+ }
+}
+
+func TestTypingAppendsAfterPrefill(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "draft")
+ typeRunes(m, "-2")
+ if m.Value() != "draft-2" {
+ t.Errorf("Value() = %q, want draft-2 (cursor must start at end of prefill)", m.Value())
+ }
+}
+
+func TestEnterConfirms(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "")
+ typeRunes(m, "hello")
+ res, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if res != ResultConfirm {
+ t.Errorf("Enter gave %v, want ResultConfirm", res)
+ }
+ if m.Value() != "hello" {
+ t.Errorf("Value() = %q, want hello", m.Value())
+ }
+}
+
+func TestEscCancels(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "")
+ res, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if res != ResultCancel {
+ t.Errorf("Esc gave %v, want ResultCancel", res)
+ }
+}
+
+func TestOrdinaryKeysReturnNone(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "")
+ res, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ if res != ResultNone {
+ t.Errorf("rune key gave %v, want ResultNone", res)
+ }
+}
+
+func TestValueTrimsSurroundingSpace(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "")
+ typeRunes(m, " spaced ")
+ if m.Value() != "spaced" {
+ t.Errorf("Value() = %q, want spaced", m.Value())
+ }
+}
+
+func TestOpenClearsPreviousError(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "")
+ m.SetError("Name taken")
+ m.Open("New note", "~/Notes/", "")
+ if m.err != "" {
+ t.Errorf("err = %q after reopen, want empty", m.err)
+ }
+}
+
+func TestTypingClearsError(t *testing.T) {
+ m := newModel()
+ m.Open("New note", "~/Notes/", "")
+ m.SetError("Type a name first")
+ typeRunes(m, "a")
+ if m.err != "" {
+ t.Errorf("err = %q after typing, want empty", m.err)
+ }
+}