▍ humdrum codex / glint v1.1.2
license AGPL-3.0

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)
+	}
+}