Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 63 additions & 17 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type model struct {
interval textinput.Model
duration textinput.Model
output textinput.Model
fps textinput.Model
focusIndex int
inputs []textinput.Model
spinner spinner.Model
Comment on lines +51 to 54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The hardcoded indices for inputs (0,1,2,3,4) make the menu brittle to future changes.

Multiple places now assume specific positions in inputs (e.g. FPS at 2, output at 3, button at 4 via m.inputs[3].Value(), if m.focusIndex == 4, etc.). This will be fragile if the number or order of inputs changes.

Defining named index constants (e.g. const idxInterval = iota; idxDuration; idxFPS; idxOutput; idxButton) and deriving the button index from len(m.inputs) where possible would keep the logic consistent as the slice evolves.

Suggested implementation:

// buttonIndex returns the index representing the submit/confirm button.
// It is derived from the current number of inputs to avoid hardcoding.
func (m model) buttonIndex() int {
	return len(m.inputs)
}

// Styles
func initialModel() model {
m.inputs[idxInterval]
m.inputs[idxDuration]
m.inputs[idxFPS]
m.inputs[idxOutput]
if m.focusIndex == m.buttonIndex() {
m.focusIndex = m.buttonIndex()

You should also:

  1. Search the rest of main.go for any remaining hardcoded uses of indices:

    • m.focusIndex == 0, == 1, == 2, == 3 and replace them with idxInterval, idxDuration, idxFPS, idxOutput respectively.
    • Any m.focusIndex == 4, m.focusIndex = 4, or comparisons like > 4, < 4 should be revised to use m.buttonIndex() or len(m.inputs) as appropriate.
    • Any other occurrences of m.inputs[0], m.inputs[1], m.inputs[2], m.inputs[3] that are not caught by the simple patterns above (e.g. inside composite expressions) should be updated manually to use idxInterval, idxDuration, idxFPS, and idxOutput.
  2. If you ever add more inputs or change their order in the inputs slice, only the slice construction and the index constants (if the semantic mapping changes) should need to be updated; the button will remain correctly derived from len(m.inputs).

Expand All @@ -60,6 +61,8 @@ type model struct {
recordingDone bool
finalMessage string
err error
intervalValue float64
fpsValue float64
}

// Styles
Expand Down Expand Up @@ -102,7 +105,7 @@ var (
func initialModel() model {
m := model{
state: stateMenu,
inputs: make([]textinput.Model, 3),
inputs: make([]textinput.Model, 4),
spinner: spinner.New(),
logs: make([]string, 0),
}
Expand All @@ -124,6 +127,13 @@ func initialModel() model {
m.duration.Width = 30
m.duration.Prompt = "│ "

// Setup fps input
m.fps = textinput.New()
m.fps.Placeholder = "30" // Default to 30 FPS
m.fps.CharLimit = 10
m.fps.Width = 30
m.fps.Prompt = "│ "

// Setup output input
m.output = textinput.New()
m.output.Placeholder = "timelapse.mp4"
Expand All @@ -133,7 +143,8 @@ func initialModel() model {

m.inputs[0] = m.interval
m.inputs[1] = m.duration
m.inputs[2] = m.output
m.inputs[2] = m.fps
m.inputs[3] = m.output

m.spinner.Spinner = spinner.Dot
m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4"))
Expand Down Expand Up @@ -214,26 +225,29 @@ func (m model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.startRecording()
}

// Cycle through inputs
// Cycle through inputs and button
if s == "up" || s == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}

if m.focusIndex > len(m.inputs) {
// Wrap around
if m.focusIndex > len(m.inputs) { // len(m.inputs) is 4. indices 0,1,2,3 for inputs, 4 for button
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs)
m.focusIndex = len(m.inputs) // Go to button
}

cmds := make([]tea.Cmd, len(m.inputs))
for i := 0; i <= len(m.inputs)-1; i++ {
var cmds []tea.Cmd
for i := 0; i < len(m.inputs); i++ {
if i == m.focusIndex {
cmds[i] = m.inputs[i].Focus()
// Set focused state
cmds = append(cmds, m.inputs[i].Focus())
m.inputs[i].PromptStyle = focusedStyle
m.inputs[i].TextStyle = focusedStyle
} else {
// Remove focused state
m.inputs[i].Blur()
m.inputs[i].PromptStyle = noStyle
m.inputs[i].TextStyle = noStyle
Expand All @@ -243,9 +257,13 @@ func (m model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}

// Handle character input for focused field
cmd := m.updateInputs(msg)
return m, cmd
// Handle character input for focused field if it's an input field
if m.focusIndex < len(m.inputs) {
cmd := m.updateInputs(msg)
return m, cmd
}

return m, nil
}

func (m model) updateRecording(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -294,32 +312,46 @@ func (m model) startRecording() (tea.Model, tea.Cmd) {
return m, nil
}

output := m.inputs[2].Value()
output := m.inputs[3].Value()
if output == "" {
output = "timelapse.mp4"
}

fps := m.inputs[2].Value()
if fps == "" {
fps = "30"
}
fpsFloat, err := strconv.ParseFloat(fps, 64)
if err != nil || fpsFloat <= 0 {
m.state = stateError
m.finalMessage = "Invalid FPS value (use a positive number)"
Comment on lines +324 to +327

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Using a float for FPS and formatting with %.2f may be unnecessarily permissive and could lead to surprising values.

Right now FPS is parsed as float64 and formatted to two decimals before passing to the Python script, so inputs like 29.9999 end up as 30.00 without the user realizing. If FPS should be integral, parsing as int (e.g. strconv.Atoi) would clarify intent and avoid rounding. If fractional FPS is expected, consider either preserving the original validated string when building the command or tightening validation to constrain allowed decimal places so the rounding behavior is explicit and predictable.

Suggested implementation:

	if fps == "" {
		fps = "30"
	}

	fpsInt, err := strconv.Atoi(fps)
	if err != nil || fpsInt <= 0 {
		m.state = stateError
		m.finalMessage = "Invalid FPS value (use a positive integer)"
		return m, nil
	}
  1. Anywhere fpsFloat is used later in this file (e.g. when building the Python command), update the code to use either:
    • fpsInt (and format it with %d), or
    • the validated fps string directly without "%.2f" formatting.
  2. Remove any "%.2f" formatting of the FPS value to avoid hidden rounding and keep the behavior aligned with the integer validation.
  3. If there are function signatures or struct fields that currently use float64 for FPS, consider changing them to int to keep the type consistent across the codebase.

return m, nil
}

// Change state to recording
m.state = stateRecording
m.startTime = time.Now()
m.recordingDone = false
m.intervalValue = intervalFloat
m.fpsValue = fpsFloat

// Start the Python subprocess
return m, tea.Batch(
m.spinner.Tick,
tick(),
m.runTimelapse(intervalFloat, durationFloat, output),
m.runTimelapse(intervalFloat, durationFloat, fpsFloat, output),
)
}

func (m model) runTimelapse(interval, duration float64, output string) tea.Cmd {
func (m model) runTimelapse(interval, duration, fps float64, output string) tea.Cmd {
return func() tea.Msg {
// Build command
cmd := exec.Command(
"python3",
"timelapse.py",
"-i", fmt.Sprintf("%.2f", interval),
"-d", fmt.Sprintf("%.2f", duration),
"-f", fmt.Sprintf("%.2f", fps),
"-o", output,
)

Expand Down Expand Up @@ -438,8 +470,8 @@ func (m model) viewMenu() string {
b.WriteString(label + "\n")
b.WriteString(m.inputs[1].View() + "\n\n")

// Output input
label = "Output file:"
// FPS input
label = "Output FPS:"
if m.focusIndex == 2 {
label = focusedStyle.Render("▸ " + label)
} else {
Expand All @@ -448,9 +480,19 @@ func (m model) viewMenu() string {
b.WriteString(label + "\n")
b.WriteString(m.inputs[2].View() + "\n\n")

// Output input
label = "Output file:"
if m.focusIndex == 3 {
label = focusedStyle.Render("▸ " + label)
} else {
label = blurredStyle.Render(" " + label)
}
b.WriteString(label + "\n")
b.WriteString(m.inputs[3].View() + "\n\n")

// Start button
button := "[ Start Recording ]"
if m.focusIndex == 3 {
if m.focusIndex == 4 {
button = focusedStyle.Render("▸ " + button)
} else {
button = blurredStyle.Render(" " + button)
Expand Down Expand Up @@ -483,6 +525,10 @@ func (m model) viewRecording() string {
}

b.WriteString(fmt.Sprintf("\nElapsed: %s\n", elapsed.Round(time.Second)))
if m.fpsValue > 0 {
recordedTime := time.Duration(float64(m.progress.current)/m.fpsValue*float64(time.Second))
b.WriteString(fmt.Sprintf("Recorded: %s\n", recordedTime.Round(time.Second)))
}

// Show recent logs
if len(m.logs) > 0 {
Expand Down