SF333PAKCY4KGEUFK6H6BV5VL7VOD32MOFWGODIPDH6RVPZQKZGAC AJD4ARTC6CE3GZDCRJD6Q6OFHJMBOXD6W4H445BD6R7QVZQBIBVQC IR2UOB6OQB6LJKHFRSIS3OJVYFVK4LB6P2TD5NDJBNHVGNW6VB4AC NEKQXZAB2LNUKHU4YGIJW7IK7IOFHLQMYSHCJDWEW46HNC4ZDNGQC Q4YXRA3T5JUJEAGNLAY2OPFBO6Q6CH43P7KXQKKINZX6RT2PRM3QC MF6A3QKQ6SU7PYAHJOO7YFCRB47DO7FHRA7OIZ7KHQJYJR77PAIQC DBOROCRFD6A5SJBMFYFEJI5S5M77X4EFEK6KDQWA5QDMQJKIHRWQC 2IURSWW3ZXRBH3DPJO437YE2FGMG54MPFL6W6UCEF5HODPVWVVTQC U6JEEU5O477ZOJ5UMRMOJSGPEJEU6Q7KMPKUSDF56CYVUJWL7QBQC SPHUX2CTF2S2TXEO3TRHNY7NIJ42G5KEWZRHQPZWXP77SJS5WJUAC GQNMVJQBC6DRV5XGK3K5L7YWG2GJUXR7EQE3OHNW72XK6BFY3AHQC VBFPFPJ4UR2JASCF7MVZDM3DC5XKITAFCPROT64TIN72KKQMO53AC 7SMHQHQGGCPR44NNBHAELM46EOREVGRF32YB66FYZALQSDRC3CJQC SB4FZEB6ZLUHQNM3M76OGNNJY6THOF55S6JO6Q7IGXWE7OA7INFAC RUF5K5CL542GK5UIIIBHPIMGGCXU72IWS5OFBVTI5DRX36OSPJDAC 2TDG53JBZHZA6ZPYONPINKVDV4UXLP4T4CI5C2MEZIIYO7DQE5RAC 5KIKDA72HM6JFIPKOWGLM2EO7D5PTSK7WEVYV3YZWGMG3M34PJXQC W3A2EECCD23SVHJZN6MXPH2PAVFHH5CNFD2XHPQRRW6M4GUTG3FAC // SecondaryBindings extends a primary binding with per-species calltype// choices. Outer key is the primary binding key; inner map is// single-char key -> calltype string. Invoked via Shift+primary-key.SecondaryBindings map[string]map[string]string `json:"secondary_bindings,omitempty"`
// Secondary-wait mode: next keypress is interpreted as a calltype key// for the species we just labeled via Shift+primary.if m.awaitingSecondaryFor != "" {primary := m.awaitingSecondaryForm.awaitingSecondaryFor = ""// Esc cancels wait mode; species stays labeled without calltype,// segment does not advance.if key.Code == tea.KeyEscape || key.Code == tea.KeyEsc {return m, nil}s := msg.String()if len(s) == 1 {if callType, ok := m.state.Config.SecondaryBindings[primary][s]; ok {if m.state.Player != nil {m.state.Player.Stop()}m.state.ApplyCallTypeOnly(callType)if err := m.state.Save(); err != nil {m.err = err.Error()}if !m.state.NextSegment() {m.quitting = truereturn m, tea.Quit}return m, m.segmentChangeCmd()}}// Unknown key — fall through to normal handling of this keypress.}
if len(msg.String()) == 1 {k := msg.String()
s := msg.String()if len(s) == 1 {k := s// Shift+letter: if the lowercase primary has secondary bindings,// label species-only and enter wait mode. Otherwise map to the// lowercase equivalent and dispatch as a normal primary keypress.if key.Mod&tea.ModShift != 0 {lower := strings.ToLower(s)if lower != s {if m.state.HasSecondary(lower) {if result := m.state.ParseKeyBuffer(lower); result != nil {if m.state.Player != nil {m.state.Player.Stop()}m.state.ApplyBinding(&tools.BindingResult{Species: result.Species})if err := m.state.Save(); err != nil {m.err = err.Error()}m.awaitingSecondaryFor = lowerreturn m, nil}}k = lower}}
Night boolDay boolLat float64Lng float64Timezone string
// SecondaryBindings maps a primary binding key to per-species calltype// keys. Invoked via Shift+primary-key: the species is labeled without// advancing, and the next key is interpreted as a calltype.SecondaryBindings map[string]map[string]stringNight boolDay boolLat float64Lng float64Timezone string
// ApplyCallTypeOnly sets the CallType on the current segment's first// filter-matching label. Used after a Shift+primary keypress labeled the// species and we now receive the secondary key for the calltype.// No-op if there is no matching label to update.func (s *ClassifyState) ApplyCallTypeOnly(callType string) {seg := s.CurrentSegment()if seg == nil {return}df := s.CurrentFile()if df == nil {return}filterLabels := seg.GetFilterLabels(s.Config.Filter)if len(filterLabels) == 0 {return}df.Meta.Reviewer = s.Config.ReviewerfilterLabels[0].CallType = callType
// Validate secondary_bindings: each outer key must exist in bindings,// each inner key must be a single non-reserved char, values non-empty.for primaryKey, inner := range cfg.Classify.SecondaryBindings {if _, ok := cfg.Classify.Bindings[primaryKey]; !ok {fmt.Fprintf(os.Stderr,"Error: secondary_bindings key %q in %s has no matching primary binding\n",primaryKey, cfgPath)os.Exit(1)}for k, v := range inner {if len(k) != 1 {fmt.Fprintf(os.Stderr,"Error: secondary_bindings[%q] key %q in %s must be a single character\n",primaryKey, k, cfgPath)os.Exit(1)}if purpose, reserved := reservedClassifyKeys[k]; reserved {fmt.Fprintf(os.Stderr,"Error: secondary_bindings[%q] key %q in %s is reserved by the TUI for %s — pick a different key.\n",primaryKey, k, cfgPath, purpose)os.Exit(1)}if v == "" {fmt.Fprintf(os.Stderr,"Error: secondary_bindings[%q][%q] in %s has empty calltype\n",primaryKey, k, cfgPath)os.Exit(1)}}}
Folder: folder,File: file,Filter: filter,Species: speciesName,CallType: callType,Certainty: certainty,Sample: sample,Goto: gotoFile,Reviewer: cfg.Classify.Reviewer,Color: cfg.Classify.Color,ImageSize: cfg.Classify.ImgDims,Sixel: cfg.Classify.Sixel,ITerm: cfg.Classify.ITerm,Bindings: bindings,Night: night,Day: day,Lat: lat,Lng: lng,Timezone: timezone,
Folder: folder,File: file,Filter: filter,Species: speciesName,CallType: callType,Certainty: certainty,Sample: sample,Goto: gotoFile,Reviewer: cfg.Classify.Reviewer,Color: cfg.Classify.Color,ImageSize: cfg.Classify.ImgDims,Sixel: cfg.Classify.Sixel,ITerm: cfg.Classify.ITerm,Bindings: bindings,SecondaryBindings: cfg.Classify.SecondaryBindings,Night: night,Day: day,Lat: lat,Lng: lng,Timezone: timezone,
## [2026-04-22] `calls classify`: Shift+primary secondary keybindings for calltype editingAdds a per-species secondary-binding layer to the classify TUI. Primary flow isunchanged (keypress → label → save → advance). When a primary key has`secondary_bindings` configured, pressing **Shift+primary-key** labels thespecies with an empty calltype, skips the auto-advance, and enters a one-shotwait state; the next keypress is looked up in the secondary map and sets thecalltype before advancing. Esc exits the wait state without advancing. Anynon-matching key falls through to normal handling.Motivation: species like common chaffinch have multiple calltypes (alarm,contact, song) that couldn't be assigned without burning extra keybindings onevery species. Secondary bindings are per-species (not global) to avoidaccidental mislabels, and deliberately unlisted in the help bar — users knowtheir own config.Example config:```json"classify": {"bindings": { "c": "comcha" },"secondary_bindings": {"c": { "a": "alarm", "s": "song", "n": "contact" }}}```Shift+primary on a key with no `secondary_bindings` entry falls back to normalprimary behavior, so existing configs are unaffected.**Files changed:**- `utils/config.go` — new `SecondaryBindings` field on `ClassifyFileConfig`.- `cmd/calls_classify.go` — validation (outer key must exist in bindings,inner keys single-char non-reserved, values non-empty) and passthrough to`ClassifyConfig`.- `tools/calls_classify.go` — `SecondaryBindings` field on `ClassifyConfig`,new `ApplyCallTypeOnly` and `HasSecondary` methods.- `tui/classify.go` — `awaitingSecondaryFor` model field, wait-mode interceptat top of `handleKey`, Shift+letter detection in the default branch, `…`indicator on the segment info line while waiting.