▍ humdrum codex / ticktock v0.0.2

feat(day): Ctrl+S submits the edit form from any field

b042fcd3db2541421b3342d150d094fafa087fcd
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-07 20:34

parent b9c9b976

feat(day): Ctrl+S submits the edit form from any field

Terminals can't deliver Cmd/Ctrl+Enter to a TUI, so use Ctrl+S as save. huh
writes each keystroke live into the shared *fv, so submit-from-anywhere reads
current values without walking to the last field. Esc still cancels.

3 files changed

internal/tui/day/model.go +25 −12
@@ -127,24 +127,37 @@ 	return m, cmd
 }
 
 func (m Model) updateEditing(msg tea.Msg) (tea.Model, tea.Cmd) {
-	if km, ok := msg.(tea.KeyMsg); ok && km.String() == "esc" {
-		m.editing = false
-		m.form = nil
-		return m, nil
+	if km, ok := msg.(tea.KeyMsg); ok {
+		switch km.String() {
+		case "esc":
+			m.editing = false
+			m.form = nil
+			return m, nil
+		case "ctrl+s":
+			// Submit from any field. huh writes each keystroke live into the
+			// shared *fv, so current values are already captured.
+			return m.finishEdit()
+		}
 	}
 	f, cmd := m.form.Update(msg)
 	if ff, ok := f.(*huh.Form); ok {
 		m.form = ff
 	}
 	if m.form.State == huh.StateCompleted {
-		m.editing = false
-		if err := applyEdit(m.store, m.date, m.editOrig, *m.fv); err != nil {
-			m.status = "edit failed: " + err.Error()
-		} else {
-			m.status = "updated " + m.editKey
-		}
-		m.form = nil
-		return m, m.load()
+		return m.finishEdit()
 	}
 	return m, cmd
 }
+
+// finishEdit writes the current form values through the store and returns to the
+// table. Shared by Ctrl+S (submit-from-anywhere) and huh's own completion.
+func (m Model) finishEdit() (tea.Model, tea.Cmd) {
+	m.editing = false
+	if err := applyEdit(m.store, m.date, m.editOrig, *m.fv); err != nil {
+		m.status = "edit failed: " + err.Error()
+	} else {
+		m.status = "updated " + m.editKey
+	}
+	m.form = nil
+	return m, m.load()
+}
internal/tui/day/model_test.go +37 −0
@@ -235,6 +235,43 @@ 		t.Errorf("stored start = %q, want 08:00 (typed edit did not reach the store)", got)
 	}
 }
 
+// TestCtrlSSubmitsFromAnyField retypes the start field then submits with Ctrl+S
+// WITHOUT tabbing to the last field, and asserts the typed value is stored.
+func TestCtrlSSubmitsFromAnyField(t *testing.T) {
+	fs := &fakeStore{days: map[string][]store.Entry{"2026-07-07": sample()}}
+	tm := teatest.NewTestModel(t, New(fs, "2026-07-07"),
+		teatest.WithInitialTermSize(140, 24))
+	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+		return bytes.Contains(b, []byte("ARCHER"))
+	}, teatest.WithDuration(2*time.Second))
+	tm.Send(tea.KeyMsg{Type: tea.KeyEnter})
+	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+		return bytes.Contains(b, []byte("start (HH:MM)"))
+	}, teatest.WithDuration(2*time.Second))
+
+	for i := 0; i < 6; i++ {
+		tm.Send(tea.KeyMsg{Type: tea.KeyBackspace})
+	}
+	for _, r := range "08:00" {
+		tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+	}
+	// Submit from the first field — no tabbing through the rest.
+	tm.Send(tea.KeyMsg{Type: tea.KeyCtrlS})
+
+	teatest.WaitFor(t, tm.Output(), func(b []byte) bool {
+		return bytes.Contains(b, []byte("updated 2026-07-07-01"))
+	}, teatest.WithDuration(2*time.Second))
+	tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+	tm.WaitFinished(t, teatest.WithFinalTimeout(2*time.Second))
+
+	if len(fs.updated) != 1 {
+		t.Fatalf("SafeReplace calls = %d, want 1", len(fs.updated))
+	}
+	if got := fs.updated[0].Start.Format("15:04"); got != "08:00" {
+		t.Errorf("stored start = %q, want 08:00", got)
+	}
+}
+
 func TestEmptyDayRendersNoEntries(t *testing.T) {
 	m := loaded(nil, 0)
 	view := m.View()
internal/tui/day/view.go +2 −1
@@ -52,7 +52,8 @@ 	if m.quit {
 		return ""
 	}
 	if m.editing && m.form != nil {
-		return headerLine(m.date) + "\n\n" + m.form.View()
+		hint := footerStyle.Render("ctrl+s save · esc cancel")
+		return headerLine(m.date) + "\n\n" + m.form.View() + "\n" + hint
 	}
 	body := m.tbl.View()
 	if len(m.entries) == 0 {