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 {