Add timestamp editor

This commit is contained in:
Martin Pander
2026-02-02 10:55:47 +01:00
parent 7032d0fa54
commit fc8e9481c3
19 changed files with 922 additions and 113 deletions

View File

@ -13,7 +13,7 @@ type Item struct {
text string
}
func NewItem(text string) Item { return Item{text: text} }
func NewItem(text string) Item { return Item{text: text} }
func (i Item) Title() string { return i.text }
func (i Item) Description() string { return "" }
func (i Item) FilterValue() string { return i.text }
@ -206,4 +206,4 @@ func (p *Picker) SelectItemByFilterValue(filterValue string) {
break
}
}
}
}

View File

@ -0,0 +1,409 @@
package timestampeditor
import (
"log/slog"
"strings"
"tasksquire/common"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
timeFormat = "20060102T150405Z" // Timewarrior format
)
// Field represents which field is currently focused
type Field int
const (
TimeField Field = iota
DateField
)
// TimestampEditor is a component for editing timestamps with separate time and date fields
type TimestampEditor struct {
common *common.Common
// Current timestamp value
timestamp time.Time
isEmpty bool // Track if timestamp is unset
// UI state
focused bool
currentField Field
// Dimensions
width int
height int
// Title and description
title string
description string
// Validation
validate func(time.Time) error
err error
}
// New creates a new TimestampEditor with no initial timestamp
func New(com *common.Common) *TimestampEditor {
return &TimestampEditor{
common: com,
timestamp: time.Time{}, // Zero time
isEmpty: true, // Start empty
focused: false,
currentField: TimeField,
validate: func(time.Time) error { return nil },
}
}
// Title sets the title of the timestamp editor
func (t *TimestampEditor) Title(title string) *TimestampEditor {
t.title = title
return t
}
// Description sets the description of the timestamp editor
func (t *TimestampEditor) Description(description string) *TimestampEditor {
t.description = description
return t
}
// Value sets the initial timestamp value
func (t *TimestampEditor) Value(timestamp time.Time) *TimestampEditor {
t.timestamp = timestamp
t.isEmpty = timestamp.IsZero()
return t
}
// ValueFromString sets the initial timestamp from a timewarrior format string
func (t *TimestampEditor) ValueFromString(s string) *TimestampEditor {
if s == "" {
t.timestamp = time.Time{}
t.isEmpty = true
return t
}
parsed, err := time.Parse(timeFormat, s)
if err != nil {
slog.Error("Failed to parse timestamp", "error", err)
t.timestamp = time.Time{}
t.isEmpty = true
return t
}
t.timestamp = parsed.Local()
t.isEmpty = false
return t
}
// GetValue returns the current timestamp
func (t *TimestampEditor) GetValue() time.Time {
return t.timestamp
}
// GetValueString returns the timestamp in timewarrior format, or empty string if unset
func (t *TimestampEditor) GetValueString() string {
if t.isEmpty {
return ""
}
return t.timestamp.UTC().Format(timeFormat)
}
// Validate sets the validation function
func (t *TimestampEditor) Validate(validate func(time.Time) error) *TimestampEditor {
t.validate = validate
return t
}
// Error returns the validation error
func (t *TimestampEditor) Error() error {
return t.err
}
// Focus focuses the timestamp editor
func (t *TimestampEditor) Focus() tea.Cmd {
t.focused = true
return nil
}
// Blur blurs the timestamp editor
func (t *TimestampEditor) Blur() tea.Cmd {
t.focused = false
t.err = t.validate(t.timestamp)
return nil
}
// SetSize sets the size of the timestamp editor
func (t *TimestampEditor) SetSize(width, height int) {
t.width = width
t.height = height
}
// Init initializes the timestamp editor
func (t *TimestampEditor) Init() tea.Cmd {
return nil
}
// Update handles messages for the timestamp editor
func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if !t.focused {
return t, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
t.err = nil
switch msg.String() {
// Navigation between fields
case "h", "left":
t.currentField = TimeField
case "l", "right":
t.currentField = DateField
// Time field adjustments (lowercase - 5 minutes)
case "j":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(-5)
} else {
t.adjustDate(-1)
}
case "k":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(5)
} else {
t.adjustDate(1)
}
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week)
case "J":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(-30)
} else {
t.adjustDate(-7)
}
case "K":
// Set current time on first edit if empty
if t.isEmpty {
t.setCurrentTime()
}
if t.currentField == TimeField {
t.adjustTime(30)
} else {
t.adjustDate(7)
}
// Remove timestamp
case "d":
t.timestamp = time.Time{}
t.isEmpty = true
}
}
return t, nil
}
// setCurrentTime sets the timestamp to the current time and marks it as not empty
func (t *TimestampEditor) setCurrentTime() {
now := time.Now()
// Snap to nearest 5 minutes
minute := now.Minute()
remainder := minute % 5
if remainder != 0 {
if remainder < 3 {
// Round down
now = now.Add(-time.Duration(remainder) * time.Minute)
} else {
// Round up
now = now.Add(time.Duration(5-remainder) * time.Minute)
}
}
// Zero out seconds and nanoseconds
t.timestamp = time.Date(
now.Year(),
now.Month(),
now.Day(),
now.Hour(),
now.Minute(),
0, 0,
now.Location(),
)
t.isEmpty = false
}
// adjustTime adjusts the time by the given number of minutes and snaps to nearest 5 minutes
func (t *TimestampEditor) adjustTime(minutes int) {
// Add the minutes
t.timestamp = t.timestamp.Add(time.Duration(minutes) * time.Minute)
// Snap to nearest 5 minutes
minute := t.timestamp.Minute()
remainder := minute % 5
if remainder != 0 {
if remainder < 3 {
// Round down
t.timestamp = t.timestamp.Add(-time.Duration(remainder) * time.Minute)
} else {
// Round up
t.timestamp = t.timestamp.Add(time.Duration(5-remainder) * time.Minute)
}
}
// Zero out seconds and nanoseconds
t.timestamp = time.Date(
t.timestamp.Year(),
t.timestamp.Month(),
t.timestamp.Day(),
t.timestamp.Hour(),
t.timestamp.Minute(),
0, 0,
t.timestamp.Location(),
)
}
// adjustDate adjusts the date by the given number of days
func (t *TimestampEditor) adjustDate(days int) {
t.timestamp = t.timestamp.AddDate(0, 0, days)
}
// View renders the timestamp editor
func (t *TimestampEditor) View() string {
var sb strings.Builder
styles := t.getStyles()
// Render title if present
if t.title != "" {
sb.WriteString(styles.title.Render(t.title))
if t.err != nil {
sb.WriteString(styles.errorIndicator.String())
}
sb.WriteString("\n")
}
// Render description if present
if t.description != "" {
sb.WriteString(styles.description.Render(t.description))
sb.WriteString("\n")
}
// Render the time and date fields side by side
var timeStr, dateStr string
if t.isEmpty {
timeStr = "--:--"
dateStr = "--- ----------"
} else {
timeStr = t.timestamp.Format("15:04")
dateStr = t.timestamp.Format("Mon 2006-01-02")
}
var timeField, dateField string
if t.currentField == TimeField {
timeField = styles.selectedField.Render(timeStr)
dateField = styles.unselectedField.Render(dateStr)
} else {
timeField = styles.unselectedField.Render(timeStr)
dateField = styles.selectedField.Render(dateStr)
}
fieldsRow := lipgloss.JoinHorizontal(lipgloss.Top, timeField, " ", dateField)
sb.WriteString(fieldsRow)
return styles.base.Render(sb.String())
}
// getHelpText returns the help text based on the current field
func (t *TimestampEditor) getHelpText() string {
if t.currentField == TimeField {
return "h/l: switch field • j/k: ±5min • J/K: ±30min • d: remove"
}
return "h/l: switch field • j/k: ±1day • J/K: ±1week • d: remove"
}
// Styles for the timestamp editor
type timestampEditorStyles struct {
base lipgloss.Style
title lipgloss.Style
description lipgloss.Style
errorIndicator lipgloss.Style
selectedField lipgloss.Style
unselectedField lipgloss.Style
help lipgloss.Style
}
// getStyles returns the styles for the timestamp editor
func (t *TimestampEditor) getStyles() timestampEditorStyles {
theme := t.common.Styles.Form
var styles timestampEditorStyles
if t.focused {
styles.base = lipgloss.NewStyle()
styles.title = theme.Focused.Title
styles.description = theme.Focused.Description
styles.errorIndicator = theme.Focused.ErrorIndicator
styles.selectedField = lipgloss.NewStyle().
Bold(true).
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("12"))
styles.unselectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8"))
styles.help = lipgloss.NewStyle().
Foreground(lipgloss.Color("8")).
Italic(true)
} else {
styles.base = lipgloss.NewStyle()
styles.title = theme.Blurred.Title
styles.description = theme.Blurred.Description
styles.errorIndicator = theme.Blurred.ErrorIndicator
styles.selectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8"))
styles.unselectedField = lipgloss.NewStyle().
Padding(0, 2).
Border(lipgloss.HiddenBorder())
styles.help = lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
}
return styles
}
// Skip returns whether the timestamp editor should be skipped
func (t *TimestampEditor) Skip() bool {
return false
}
// Zoom returns whether the timestamp editor should be zoomed
func (t *TimestampEditor) Zoom() bool {
return false
}
// KeyBinds returns the key bindings for the timestamp editor
func (t *TimestampEditor) KeyBinds() []key.Binding {
return []key.Binding{
t.common.Keymap.Left,
t.common.Keymap.Right,
t.common.Keymap.Up,
t.common.Keymap.Down,
}
}

View File

@ -149,7 +149,7 @@ func (m *Model) parseRowStyles(rows timewarrior.Intervals) []lipgloss.Style {
for i := range rows {
// Default style
styles[i] = m.common.Styles.Base.Inherit(m.styles.Cell).Margin(m.styles.Cell.GetMargin()).Padding(m.styles.Cell.GetPadding())
// If active, maybe highlight?
if rows[i].IsActive() {
if c, ok := m.common.Styles.Colors["active"]; ok && c != nil {