package grid import ( "fmt" "hash/fnv" "strings" "time" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-runewidth" "ticktock/internal/store" ) var ( 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) gCal = lipgloss.NewStyle().Foreground(lipgloss.Color("#6CB6FF")) // calendar accent ) // projectPalette gives each project a stable, distinct color. Assignment is by // a hash of the project name, so a project keeps the same color across runs. var projectPalette = []lipgloss.Color{ "#78B892", // green "#D188C4", // pink "#6CB6FF", // blue "#E0A458", // orange "#B48EAD", // mauve "#E8C547", // yellow "#5FB3B3", // teal "#EC6A88", // red } func projectColor(project string) lipgloss.Color { if project == "" { return gMuted } h := fnv.New32a() _, _ = h.Write([]byte(project)) return projectPalette[int(h.Sum32())%len(projectPalette)] } func projectStyle(project string) lipgloss.Style { return lipgloss.NewStyle().Foreground(projectColor(project)) } // distinctProjects lists the projects present in entries, in first-seen order. func distinctProjects(entries []store.Entry) []string { seen := map[string]bool{} var out []string for _, e := range entries { if e.Project != "" && !seen[e.Project] { seen[e.Project] = true out = append(out, e.Project) } } return out } func gHeaderLine(date string) string { t, err := time.ParseInLocation("2006-01-02", date, time.Local) label := date if err == nil { label = t.Format("Monday, 02 Jan 2006") } return gHeader.Render("<< " + label + " >>") } const ( actWidthMin = 22 // floor before a WindowSizeMsg arrives / on narrow terminals loggedWidth = 40 // gridRow overhead around the ACTIVITY cell: gutterL+sp+time(5)+sp + │+sp // before the cell, then sp+│+gutterR after it = 14 fixed columns. rowFixed = 14 // columns kept for the LOGGED lane when sizing ACTIVITY responsively. loggedReserve = 24 ) // activityWidth sizes the ACTIVITY column to fill the terminal, leaving room for // the TIME prefix, dividers, and a LOGGED reserve — floored at actWidthMin so it // stays readable before the first WindowSizeMsg (m.width == 0) or when narrow. func (m Model) activityWidth() int { aw := m.width - rowFixed - loggedReserve if aw < actWidthMin { return actWidthMin } return aw } func minuteLabel(min int) string { return fmt.Sprintf("%02d:%02d", (min/60)%24, min%60) } // trunc shortens s so its terminal display width is at most w, appending "…" // when it truncates. Width is measured in display cells (via go-runewidth), not // runes, so double-width glyphs like emoji in calendar labels are counted as 2 // — otherwise a label with an emoji overflows its column and the terminal wraps // it, shoving the dividers and the LOGGED lane out of alignment. func trunc(s string, w int) string { if runewidth.StringWidth(s) <= w { return s } if w <= 1 { return runewidth.Truncate(s, w, "") } return runewidth.Truncate(s, w, "…") } // gridRow renders one line with fixed 1-col gutters on both sides of the // dividers so separators align and the cursor marker can sit next to whichever // column is active: [gutterL] time(5) │ activity(actWidth) │[gutterR] logged. func gridRow(gutterL, timeStr, actCell, gutterR, logCell string) string { // actCell is always pre-sized to actWidth display cells by renderActCell / // the gap/band builders, so print it verbatim — a %-*s here would re-pad by // rune count and re-overflow any cell containing double-width glyphs. return fmt.Sprintf("%s %-5s │ %s │%s %s", gutterL, timeStr, actCell, gutterR, logCell) } // ruleLine draws the header underline with ┼ crossings at the │ columns, sized // to the current ACTIVITY width so the crossings line up with the data dividers. func ruleLine(aw int) string { // gridRow prefix before first │: gutter(1)+sp(1)+time(5)+sp(1) = 8 chars. return gDim.Render( strings.Repeat("─", 8) + "┼" + strings.Repeat("─", aw+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 head + "\n\n" + m.form.View() + "\n" + hint } aw := m.activityWidth() var b strings.Builder b.WriteString(gHeaderLine(m.date) + "\n\n") b.WriteString(gridRow(" ", "TIME", padCell("ACTIVITY", aw), " ", "LOGGED → tock") + "\n") b.WriteString(ruleLine(aw) + "\n") lo, hi, selecting := m.selectionRange() start := m.top end := m.top + m.visibleRows() if end > m.grid.Rows { end = m.grid.Rows } for row := start; row < end; row++ { onCursor := row == m.cursor // ACTIVITY column: painted lane (calendar/active/idle) over the gap // lane; the selection band still overrides while selecting. act := strings.Repeat("░", aw) if row < len(m.activity) && m.activity[row].Kind != CellEmpty { act = renderActCell(m.activity[row], aw) } if selecting && row >= lo && row <= hi { act = gBand.Render(strings.Repeat("▓", aw)) } // LOGGED column var logged string if row < len(m.logged) { c := m.logged[row] switch c.Kind { case CellLabel: logged = trunc(c.Text, loggedWidth) case CellCont: 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 = projectStyle(c.Project).Render(logged) } else if c.Kind == CellCont { logged = projectStyle(c.Project).Faint(true).Render(logged) } } gutterL, gutterR := " ", " " if onCursor { if m.col == ColActivity { gutterL = gCursor.Render(">") } else { gutterR = gCursor.Render(">") } } timeStr := gDim.Render(minuteLabel(m.grid.SlotMinute(row))) b.WriteString(gridRow(gutterL, timeStr, act, gutterR, logged) + "\n") } b.WriteString("\n") if legend := m.projectLegend(); legend != "" { b.WriteString(legend + "\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.err != nil { help = "error: " + m.err.Error() } b.WriteString(gDim.Render(help)) return b.String() } // projectLegend renders each project present today in its own color so the // grid's colors are legible. func (m Model) projectLegend() string { projects := distinctProjects(m.entries) if len(projects) == 0 { return "" } parts := make([]string, 0, len(projects)) for _, p := range projects { parts = append(parts, projectStyle(p).Render(p)) } return gDim.Render("projects: ") + strings.Join(parts, " ") } // 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) } // padCell pads s with spaces to exactly w runes. The activity cell must be // padded BEFORE styling: gridRow's %-*s pads by byte length, which ANSI // escapes would defeat, misaligning the column divider. func padCell(s string, w int) string { if n := runewidth.StringWidth(s); n < w { return s + strings.Repeat(" ", w-n) } return s } // renderActCell paints one ACTIVITY-lane cell: label text or continuation // glyph, colored by flavor — calendar accent, idle dimmed, active plain; // continuation glyphs render faint. func renderActCell(c Cell, aw int) string { text := trunc(c.Text, aw) if c.Kind == CellCont { text = "│" } text = padCell(text, aw) style := lipgloss.NewStyle() switch c.Act { case ActCalendar: style = gCal case ActIdle: style = gDim } if c.Kind == CellCont { style = style.Faint(true) } return style.Render(text) }