fix(grid): skip pre-decided times in add form, clearer delete target, aligned columns
bc8bdbcbfe1f873f750e8b6ee81d723bffce6454
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 21:59
parent e95af753
fix(grid): skip pre-decided times in add form, clearer delete target, aligned columns - add form (space→arrow→space) now shows only project/desc/tags/note so the cursor lands on project; the selected range is shown above the form - LOGGED cursor cell is highlighted so it's obvious what enter/d will act on - delete confirm names the session (description + time range) and highlights its rows in red instead of showing only the tock key - fixed-gutter row layout aligns the column separators across header and body
5 files changed
internal/tui/form/form.go +11 −4
@@ -70,9 +70,16 @@ v.Tags, v.Note = strings.Join(e.Tags, ", "), e.Notes
return huh.NewForm(huh.NewGroup(inputs(v)...))
}
-// Blank prefills *v with start/end/project (empty desc/tags/note) for adding a
-// new entry from a grid range, and returns the form bound to v.
-func Blank(start, end, project string, v *Values) *huh.Form {
+// AddForm builds a form for a new entry whose start/end are already fixed by a
+// grid selection. It stores start/end/project into *v but shows only the
+// project/description/tags/note inputs, so the cursor lands on project instead
+// of tabbing through the pre-decided times.
+func AddForm(start, end, project string, v *Values) *huh.Form {
*v = Values{Start: start, End: end, Project: project}
- return huh.NewForm(huh.NewGroup(inputs(v)...))
+ return huh.NewForm(huh.NewGroup(
+ huh.NewInput().Title("project").Value(&v.Project),
+ huh.NewInput().Title("description").Value(&v.Desc),
+ huh.NewInput().Title("tags (comma-sep)").Value(&v.Tags),
+ huh.NewInput().Title("note").Value(&v.Note),
+ ))
}
internal/tui/form/form_test.go +4 −4
@@ -43,13 +43,13 @@ t.Fatal("expected error on bad start time")
}
}
-func TestBlankPrefillsStartEndProject(t *testing.T) {
+func TestAddFormPrefillsStartEndProject(t *testing.T) {
var v Values
- _ = Blank("10:00", "10:30", "ARCHER", &v)
+ _ = AddForm("10:00", "10:30", "ARCHER", &v)
if v.Start != "10:00" || v.End != "10:30" || v.Project != "ARCHER" {
- t.Errorf("blank prefill wrong: %+v", v)
+ t.Errorf("add-form prefill wrong: %+v", v)
}
if v.Desc != "" || v.Tags != "" || v.Note != "" {
- t.Errorf("blank should leave desc/tags/note empty: %+v", v)
+ t.Errorf("add-form should leave desc/tags/note empty: %+v", v)
}
}
internal/tui/grid/editdelete_test.go +11 −0
@@ -1,6 +1,7 @@
package grid
import (
+ "strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
@@ -63,6 +64,16 @@ }
nm, _ = mm.Update(key("y"))
if len(f.removed) != 1 || f.removed[0] != "2026-07-07-01" {
t.Fatalf("y should confirm remove: %v", f.removed)
+ }
+}
+
+func TestConfirmLabelDescribesSession(t *testing.T) {
+ f := loggedFixture()
+ m := onLoggedRow(loaded(f))
+ nm, _ := m.Update(key("d"))
+ label := nm.(Model).confirmLabel()
+ if !strings.Contains(label, "standup") || !strings.Contains(label, "08:00") {
+ t.Errorf("confirm label should name the session and range, got %q", label)
}
}
internal/tui/grid/model.go +1 −1
@@ -216,7 +216,7 @@ m.anchor = nil
start, end := m.rangeTimes(lo, hi)
m.editing, m.adding = true, true
m.fv = &form.Values{}
- m.form = form.Blank(start, end, m.cfg.Project, m.fv)
+ m.form = form.AddForm(start, end, m.cfg.Project, m.fv)
return m, m.form.Init()
}
internal/tui/grid/view.go +70 −18
@@ -13,12 +13,15 @@ gPink = lipgloss.Color("#D188C4")
gMuted = lipgloss.Color("#878580")
gGreen = lipgloss.Color("#78B892")
gPurple = lipgloss.Color("#5A2FE0")
+ gRed = lipgloss.Color("#D16969")
gHeader = lipgloss.NewStyle().Foreground(gPink).Bold(true)
gDim = lipgloss.NewStyle().Foreground(gMuted)
gLabel = lipgloss.NewStyle().Foreground(gGreen)
gBand = lipgloss.NewStyle().Foreground(gPurple)
gCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple)
+ gCurLog = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gPurple).Bold(true)
+ gDelHi = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Background(gRed).Bold(true)
)
func gHeaderLine(date string) string {
@@ -50,57 +53,88 @@ }
return string(r[:w-1]) + "…"
}
+// gridRow renders one line with a fixed 1-col gutter so every row's separators
+// align: [gutter] time(5) │ activity(actWidth) │ logged.
+func gridRow(gutter, timeStr, actCell, logCell string) string {
+ return fmt.Sprintf("%s %-5s │ %-*s │ %s", gutter, timeStr, actWidth, actCell, logCell)
+}
+
+// ruleLine draws the header underline with ┼ crossings at the │ columns.
+func ruleLine() string {
+ // gridRow prefix before first │: gutter(1)+sp(1)+time(5)+sp(1) = 8 chars.
+ return gDim.Render(
+ strings.Repeat("─", 8) + "┼" +
+ strings.Repeat("─", actWidth+2) + "┼" +
+ strings.Repeat("─", 14))
+}
+
func (m Model) View() string {
if m.quit {
return ""
}
if m.editing && m.form != nil {
+ head := gHeaderLine(m.date)
+ if m.adding && m.fv != nil {
+ head += "\n" + gDim.Render(fmt.Sprintf("new entry %s–%s", m.fv.Start, m.fv.End))
+ }
hint := gDim.Render("ctrl+s save · esc cancel")
- return gHeaderLine(m.date) + "\n\n" + m.form.View() + "\n" + hint
+ return head + "\n\n" + m.form.View() + "\n" + hint
}
var b strings.Builder
b.WriteString(gHeaderLine(m.date) + "\n\n")
- // column headers
- b.WriteString(fmt.Sprintf(" %-5s │ %-*s │ %s\n", "TIME", actWidth, "ACTIVITY", "LOGGED → tock"))
- b.WriteString(gDim.Render(" ─────┼─"+strings.Repeat("─", actWidth)+"┼─────────────") + "\n")
+ b.WriteString(gridRow(" ", "TIME", "ACTIVITY", "LOGGED → tock") + "\n")
+ b.WriteString(ruleLine() + "\n")
lo, hi, selecting := m.selectionRange()
for row := 0; row < m.grid.Rows; row++ {
- tcol := gDim.Render(fmt.Sprintf(" %-5s", minuteLabel(m.grid.SlotMinute(row))))
+ onCursor := row == m.cursor
// ACTIVITY column: gap lane, band if selected
- gap := strings.Repeat("░", actWidth)
+ act := strings.Repeat("░", actWidth)
if selecting && row >= lo && row <= hi {
- gap = gBand.Render(strings.Repeat("▓", actWidth))
+ act = gBand.Render(strings.Repeat("▓", actWidth))
}
// LOGGED column
var logged string
if row < len(m.logged) {
- switch m.logged[row].Kind {
+ c := m.logged[row]
+ switch c.Kind {
case CellLabel:
- logged = gLabel.Render(trunc(m.logged[row].Text, loggedWidth))
+ logged = trunc(c.Text, loggedWidth)
case CellCont:
- logged = gDim.Render("│")
+ logged = "│"
+ }
+ // highlight the whole session targeted for deletion
+ if m.confirmDelete && c.Key != "" && c.Key == m.confirmKey {
+ logged = gDelHi.Render(logged)
+ } else if onCursor && m.col == ColLogged && c.Kind != CellEmpty {
+ logged = gCurLog.Render(logged)
+ } else if c.Kind == CellLabel {
+ logged = gLabel.Render(logged)
+ } else if c.Kind == CellCont {
+ logged = gDim.Render(logged)
}
}
- // cursor marker on the active row
- cursorMark := " "
- if row == m.cursor {
- cursorMark = gCursor.Render(">")
+ gutter := " "
+ if onCursor {
+ gutter = gCursor.Render(">")
}
- b.WriteString(fmt.Sprintf("%s%s │ %s │ %s\n", cursorMark, tcol, gap, logged))
+ timeStr := gDim.Render(minuteLabel(m.grid.SlotMinute(row)))
+ b.WriteString(gridRow(gutter, timeStr, act, logged) + "\n")
}
b.WriteString("\n")
+ if m.confirmDelete {
+ // pre-styled; write without an outer dim wrap
+ b.WriteString(gDelHi.Render(" delete ") + " " + m.confirmLabel() + " " + gDim.Render("y = yes · n = no"))
+ return b.String()
+ }
help := "←/→ col · ↑/↓ slot · space select/add · enter edit · d delete · n/p/t day · r reload · q quit"
if m.status != "" {
help = m.status
- }
- if m.confirmDelete {
- help = "delete " + m.confirmKey + "? y = yes · n = no"
}
if m.err != nil {
help = "error: " + m.err.Error()
@@ -108,3 +142,21 @@ }
b.WriteString(gDim.Render(help))
return b.String()
}
+
+// confirmLabel describes the session pending deletion (description + range),
+// falling back to the tock key if the entry can't be found.
+func (m Model) confirmLabel() string {
+ e, ok := m.entryByKey(m.confirmKey)
+ if !ok {
+ return m.confirmKey
+ }
+ rng := e.Start.Format("15:04") + "–" + e.End.Format("15:04")
+ if e.Running() {
+ rng = e.Start.Format("15:04") + "–…"
+ }
+ desc := e.Description
+ if desc == "" {
+ desc = e.Project
+ }
+ return fmt.Sprintf("%q %s", desc, rng)
+}